├── .env.dev ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── documentation.yaml │ └── feature.yaml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── analyse.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docker-compose.yml ├── docs ├── add-new-adapter.md └── sms-pricing.md ├── phpcs.xml ├── phpunit.xml ├── src └── Utopia │ └── Messaging │ ├── Adapter.php │ ├── Adapter │ ├── Chat │ │ └── Discord.php │ ├── Email.php │ ├── Email │ │ ├── Mailgun.php │ │ ├── Mock.php │ │ ├── SMTP.php │ │ └── Sendgrid.php │ ├── Push.php │ ├── Push │ │ ├── APNS.php │ │ └── FCM.php │ ├── SMS.php │ └── SMS │ │ ├── Clickatell.php │ │ ├── Fast2SMS.php │ │ ├── GEOSMS.php │ │ ├── GEOSMS │ │ └── CallingCode.php │ │ ├── Infobip.php │ │ ├── Inforu.php │ │ ├── Mock.php │ │ ├── Msg91.php │ │ ├── Plivo.php │ │ ├── Seven.php │ │ ├── Sinch.php │ │ ├── Telesign.php │ │ ├── Telnyx.php │ │ ├── TextMagic.php │ │ ├── Twilio.php │ │ └── Vonage.php │ ├── Helpers │ └── JWT.php │ ├── Message.php │ ├── Messages │ ├── Discord.php │ ├── Email.php │ ├── Email │ │ └── Attachment.php │ ├── Push.php │ └── SMS.php │ ├── Priority.php │ └── Response.php └── tests ├── Messaging └── Adapter │ ├── Base.php │ ├── Chat │ └── DiscordTest.php │ ├── Email │ ├── EmailTest.php │ ├── MailgunTest.php │ ├── SMTPTest.php │ └── SendgridTest.php │ ├── Push │ ├── APNSTest.php │ ├── Base.php │ └── FCMTest.php │ └── SMS │ ├── Fast2SMSTest.php │ ├── GEOSMS │ └── CallingCodeTest.php │ ├── GEOSMSTest.php │ ├── InforuTest.php │ ├── Msg91Test.php │ ├── SMSTest.php │ ├── TelesignTest.php │ ├── TelnyxTest.php │ ├── TwilioTest.php │ └── VonageTest.php └── assets └── image.png /.env.dev: -------------------------------------------------------------------------------- 1 | MAILGUN_API_KEY= 2 | MAILGUN_DOMAIN= 3 | SENDGRID_API_KEY= 4 | FCM_SERVICE_ACCOUNT_JSON= 5 | FCM_TO= 6 | TWILIO_ACCOUNT_SID= 7 | TWILIO_AUTH_TOKEN= 8 | TWILIO_TO= 9 | TWILIO_FROM= 10 | TELNYX_API_KEY= 11 | TELNYX_PUBLIC_KEY= 12 | APNS_AUTHKEY= 13 | APNS_AUTH_ID= 14 | APNS_TEAM_ID= 15 | APNS_BUNDLE_ID= 16 | APNS_TO= 17 | MSG_91_SENDER_ID= 18 | MSG_91_AUTH_KEY= 19 | MSG_91_TO= 20 | MSG_91_FROM= 21 | TEST_EMAIL= 22 | TEST_FROM_EMAIL= 23 | TEST_CC_EMAIL= 24 | TEST_BCC_EMAIL= 25 | TEST_BCC_NAME= 26 | VONAGE_API_KEY= 27 | VONAGE_API_SECRET= 28 | VONAGE_TO= 29 | VONAGE_FROM= 30 | DISCORD_WEBHOOK_URL= 31 | FAST2SMS_API_KEY= 32 | FAST2SMS_SENDER_ID= 33 | FAST2SMS_MESSAGE_ID= 34 | FAST2SMS_TO= 35 | INFORU_API_TOKEN= 36 | INFORU_SENDER_ID= 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Submit a bug report to help us improve" 3 | title: "🐛 Bug Report: " 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our bug report form 🙏 10 | - type: textarea 11 | id: steps-to-reproduce 12 | validations: 13 | required: true 14 | attributes: 15 | label: "👟 Reproduction steps" 16 | description: "How do you trigger this bug? Please walk us through it step by step." 17 | placeholder: "When I ..." 18 | - type: textarea 19 | id: expected-behavior 20 | validations: 21 | required: true 22 | attributes: 23 | label: "👍 Expected behavior" 24 | description: "What did you think would happen?" 25 | placeholder: "It should ..." 26 | - type: textarea 27 | id: actual-behavior 28 | validations: 29 | required: true 30 | attributes: 31 | label: "👎 Actual Behavior" 32 | description: "What did actually happen? Add screenshots, if applicable." 33 | placeholder: "It actually ..." 34 | - type: textarea 35 | id: version 36 | attributes: 37 | label: "🎲 Version" 38 | description: "What version of are you running?" 39 | validations: 40 | required: true 41 | - type: dropdown 42 | id: operating-system 43 | attributes: 44 | label: "💻 Operating system" 45 | description: "What OS is your server / device running on?" 46 | options: 47 | - Linux 48 | - MacOS 49 | - Windows 50 | - Something else 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: environment 55 | validations: 56 | required: false 57 | attributes: 58 | label: "🧱 Your Environment" 59 | description: "Is your environment customized in any way?" 60 | placeholder: "I use Cloudflare for ..." 61 | - type: checkboxes 62 | id: no-duplicate-issues 63 | attributes: 64 | label: "👀 Have you spent some time to check if this issue has been raised before?" 65 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 66 | options: 67 | - label: "I checked and didn't find similar issue" 68 | required: true 69 | - type: checkboxes 70 | id: read-code-of-conduct 71 | attributes: 72 | label: "🏢 Have you read the Code of Conduct?" 73 | options: 74 | - label: "I have read the [Code of Conduct](https://github.com/utopia-php/messaging/blob/HEAD/CODE_OF_CONDUCT.md)" 75 | required: true 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: "📚 Documentation" 2 | description: "Report an issue related to documentation" 3 | title: "📚 Documentation: " 4 | labels: [documentation] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to make our documentation better 🙏 10 | - type: textarea 11 | id: issue-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "💭 Description" 16 | description: "A clear and concise description of what the issue is." 17 | placeholder: "Documentation should not ..." 18 | - type: checkboxes 19 | id: no-duplicate-issues 20 | attributes: 21 | label: "👀 Have you spent some time to check if this issue has been raised before?" 22 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 23 | options: 24 | - label: "I checked and didn't find similar issue" 25 | required: true 26 | - type: checkboxes 27 | id: read-code-of-conduct 28 | attributes: 29 | label: "🏢 Have you read the Code of Conduct?" 30 | options: 31 | - label: "I have read the [Code of Conduct](https://github.com/utopia-php/messaging/blob/HEAD/CODE_OF_CONDUCT.md)" 32 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature 2 | description: "Submit a proposal for a new feature" 3 | title: "🚀 Feature: " 4 | labels: [feature] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our feature request form 🙏 10 | - type: textarea 11 | id: feature-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "🔖 Feature description" 16 | description: "A clear and concise description of what the feature is." 17 | placeholder: "You should add ..." 18 | - type: textarea 19 | id: pitch 20 | validations: 21 | required: true 22 | attributes: 23 | label: "🎤 Pitch" 24 | description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable." 25 | placeholder: "In my use-case, ..." 26 | - type: checkboxes 27 | id: no-duplicate-issues 28 | attributes: 29 | label: "👀 Have you spent some time to check if this issue has been raised before?" 30 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 31 | options: 32 | - label: "I checked and didn't find similar issue" 33 | required: true 34 | - type: checkboxes 35 | id: read-code-of-conduct 36 | attributes: 37 | label: "🏢 Have you read the Code of Conduct?" 38 | options: 39 | - label: "I have read the [Code of Conduct](https://github.com/utopia-php/messaging/blob/HEAD/CODE_OF_CONDUCT.md)" 40 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | ## What does this PR do? 13 | 14 | (Provide a description of what this PR does.) 15 | 16 | ## Test Plan 17 | 18 | (Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work.) 19 | 20 | ## Related PRs and Issues 21 | 22 | (If this PR is related to any other PR or resolves any issue or related to any issue link all related PR and issues here.) 23 | 24 | ### Have you read the [Contributing Guidelines on issues](https://github.com/utopia-php/messaging/blob/master/CONTRIBUTING.md)? 25 | 26 | (Write your answer here.) 27 | -------------------------------------------------------------------------------- /.github/workflows/analyse.yml: -------------------------------------------------------------------------------- 1 | name: "Static Analysis" 2 | 3 | on: [pull_request] 4 | jobs: 5 | analyse: 6 | name: Analyse 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Run Static Analysis 18 | run: | 19 | docker run --rm -v $PWD:/app composer sh -c \ 20 | "composer install --profile --ignore-platform-reqs && composer analyse" 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Linter" 2 | 3 | on: [pull_request] 4 | jobs: 5 | lint: 6 | name: Linter 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Run Linter 18 | run: | 19 | docker run --rm -v $PWD:/app composer sh -c \ 20 | "composer install --profile --ignore-platform-reqs && composer lint" 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: [pull_request] 4 | jobs: 5 | tests: 6 | name: Unit & E2E 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 2 14 | - run: git checkout HEAD^2 15 | - name: Run Tests 16 | env: 17 | MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} 18 | MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }} 19 | SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} 20 | FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }} 21 | FCM_TO: ${{ secrets.FCM_TO }} 22 | TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} 23 | TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} 24 | TWILIO_TO: ${{ secrets.TWILIO_TO }} 25 | TWILIO_FROM: ${{ secrets.TWILIO_FROM }} 26 | TELNYX_API_KEY: ${{ secrets.TELNYX_API_KEY }} 27 | TELNYX_PUBLIC_KEY: ${{ secrets.TELNYX_PUBLIC_KEY }} 28 | APNS_AUTHKEY_8KVVCLA3HL: ${{ secrets.APNS_AUTHKEY_8KVVCLA3HL }} 29 | APNS_AUTH_ID: ${{ secrets.APNS_AUTH_ID }} 30 | APNS_TEAM_ID: ${{ secrets.APNS_TEAM_ID }} 31 | APNS_BUNDLE_ID: ${{ secrets.APNS_BUNDLE_ID }} 32 | APNS_TO: ${{ secrets.APNS_TO }} 33 | MSG_91_SENDER_ID: ${{ secrets.MSG_91_SENDER_ID }} 34 | MSG_91_AUTH_KEY: ${{ secrets.MSG_91_AUTH_KEY }} 35 | MSG_91_TO: ${{ secrets.MSG_91_TO }} 36 | MSG_91_FROM: ${{ secrets.MSG_91_FROM }} 37 | TEST_EMAIL: ${{ secrets.TEST_EMAIL }} 38 | TEST_FROM_EMAIL: ${{ secrets.TEST_FROM_EMAIL }} 39 | TEST_CC_EMAIL: ${{ secrets.TEST_CC_EMAIL }} 40 | TEST_BCC_EMAIL: ${{ secrets.TEST_BCC_EMAIL }} 41 | TEST_BCC_NAME: ${{ secrets.TEST_BCC_NAME }} 42 | VONAGE_API_KEY: ${{ secrets.VONAGE_API_KEY }} 43 | VONAGE_API_SECRET: ${{ secrets.VONAGE_API_SECRET }} 44 | VONAGE_TO: ${{ secrets.VONAGE_TO }} 45 | VONAGE_FROM: ${{ secrets.VONAGE_FROM }} 46 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 47 | FAST2SMS_API_KEY: ${{ secrets.FAST2SMS_API_KEY }} 48 | FAST2SMS_SENDER_ID: ${{ secrets.FAST2SMS_SENDER_ID }} 49 | FAST2SMS_MESSAGE_ID: ${{ secrets.FAST2SMS_MESSAGE_ID }} 50 | FAST2SMS_TO: ${{ secrets.FAST2SMS_TO }} 51 | INFORU_API_TOKEN: ${{ secrets.INFORU_API_TOKEN }} 52 | INFORU_SENDER_ID: ${{ secrets.INFORU_SENDER_ID }} 53 | run: | 54 | docker compose up -d --build 55 | sleep 5 56 | docker compose exec tests vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | Makefile 4 | .envrc 5 | .env 6 | *.p8 7 | 8 | ### Linux ### 9 | *~ 10 | 11 | # temporary files which can be created if a process still has a handle open of a deleted file 12 | .fuse_hidden* 13 | 14 | # KDE directory preferences 15 | .directory 16 | 17 | # Linux trash folder which might appear on any partition or disk 18 | .Trash-* 19 | 20 | # .nfs files are created when an open file is removed but is still being accessed 21 | .nfs* 22 | 23 | ### macOS ### 24 | # General 25 | .DS_Store 26 | .AppleDouble 27 | .LSOverride 28 | 29 | # Icon must end with two \r 30 | Icon 31 | 32 | 33 | # Thumbnails 34 | ._* 35 | 36 | # Files that might appear in the root of a volume 37 | .DocumentRevisions-V100 38 | .fseventsd 39 | .Spotlight-V100 40 | .TemporaryItems 41 | .Trashes 42 | .VolumeIcon.icns 43 | .com.apple.timemachine.donotpresent 44 | 45 | # Directories potentially created on remote AFP share 46 | .AppleDB 47 | .AppleDesktop 48 | Network Trash Folder 49 | Temporary Items 50 | .apdisk 51 | 52 | ### macOS Patch ### 53 | # iCloud generated files 54 | *.icloud 55 | 56 | ### PHPUnit ### 57 | # Covers PHPUnit 58 | # Reference: https://phpunit.de/ 59 | 60 | # Generated files 61 | .phpunit.result.cache 62 | .phpunit.cache 63 | 64 | # PHPUnit 65 | /app/phpunit.xml 66 | 67 | # Build data 68 | /build/ 69 | 70 | ### Windows ### 71 | # Windows thumbnail cache files 72 | Thumbs.db 73 | Thumbs.db:encryptable 74 | ehthumbs.db 75 | ehthumbs_vista.db 76 | 77 | # Dump file 78 | *.stackdump 79 | 80 | # Folder config file 81 | [Dd]esktop.ini 82 | 83 | # Recycle Bin used on file shares 84 | $RECYCLE.BIN/ 85 | 86 | # Windows Installer files 87 | *.cab 88 | *.msi 89 | *.msix 90 | *.msm 91 | *.msp 92 | 93 | # Windows shortcuts 94 | *.lnk 95 | 96 | # End of https://www.toptal.com/developers/gitignore/api/macos,linux,windows,phpunit -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity, expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@appwrite.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would ❤️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. 4 | 5 | ## How to Start? 6 | 7 | If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you! 8 | 9 | ## Code of Conduct 10 | 11 | Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](/CODE_OF_CONDUCT.md). 12 | 13 | ## Submit a Pull Request 🚀 14 | 15 | Branch naming convention is as following 16 | 17 | `TYPE-ISSUE_ID-DESCRIPTION` 18 | 19 | example: 20 | 21 | ``` 22 | doc-548-submit-a-pull-request-section-to-contribution-guide 23 | ``` 24 | 25 | When `TYPE` can be: 26 | 27 | - **feat** - is a new feature 28 | - **doc** - documentation only changes 29 | - **cicd** - changes related to CI/CD system 30 | - **fix** - a bug fix 31 | - **refactor** - code change that neither fixes a bug nor adds a feature 32 | 33 | **All PRs must include a commit message with the changes description!** 34 | 35 | For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: 36 | 37 | 1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date. 38 | 39 | ``` 40 | $ git pull 41 | ``` 42 | 43 | 2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
44 | 45 | ``` 46 | $ git checkout -b [name_of_your_new_branch] 47 | ``` 48 | 49 | 3. Work - commit - repeat ( be sure to be in your branch ) 50 | 51 | 4. Push changes to GitHub 52 | 53 | ``` 54 | $ git push origin [name_of_your_new_branch] 55 | ``` 56 | 57 | 5. Submit your changes for review 58 | If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. 59 | 6. Start a Pull Request 60 | Now submit the pull request and click on `Create pull request`. 61 | 7. Get a code review approval/reject 62 | 8. After approval, merge your PR 63 | 9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). 64 | 65 | ## Introducing New Features 66 | 67 | We would 💖 you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement 🙏. 68 | 69 | For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. 70 | 71 | This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. 72 | 73 | This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). 74 | 75 | ## Other Ways to Help 76 | 77 | Pull requests are great, but there are many other areas where you can help Utopia-php. 78 | 79 | ### Blogging & Speaking 80 | 81 | Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow. 82 | 83 | ### Presenting at Meetups 84 | 85 | Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! 86 | 87 | ### Sending Feedbacks & Reporting Bugs 88 | 89 | Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA). 90 | 91 | ### Submitting New Ideas 92 | 93 | If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. 94 | 95 | ### Improving Documentation 96 | 97 | Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. 98 | 99 | ### Helping Someone 100 | 101 | Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo! -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer:2.0 AS composer 2 | 3 | ARG TESTING=false 4 | ENV TESTING=$TESTING 5 | 6 | WORKDIR /usr/local/src/ 7 | 8 | COPY composer.lock /usr/local/src/ 9 | COPY composer.json /usr/local/src/ 10 | 11 | RUN composer install \ 12 | --ignore-platform-reqs \ 13 | --optimize-autoloader \ 14 | --no-plugins \ 15 | --no-scripts \ 16 | --prefer-dist 17 | 18 | FROM php:8.3.11-cli-alpine3.20 19 | 20 | WORKDIR /usr/local/src/ 21 | 22 | COPY --from=composer /usr/local/src/vendor /usr/local/src/vendor 23 | COPY . /usr/local/src/ 24 | 25 | CMD [ "tail", "-f", "/dev/null" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 utopia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utopia Messaging 2 | 3 | [![Build Status](https://travis-ci.org/utopia-php/abuse.svg?branch=master)](https://travis-ci.com/utopia-php/database) 4 | ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/messaging.svg) 5 | [![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) 6 | 7 | Utopia Messaging library is simple and lite library for sending messages using multiple messaging adapters. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). 8 | 9 | Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free, and can be used as standalone with any other PHP project or framework. 10 | 11 | ## Getting Started 12 | 13 | Install using composer: 14 | ```bash 15 | composer require utopia-php/messaging 16 | ``` 17 | 18 | ## Email 19 | 20 | ```php 21 | Hello World' 31 | ); 32 | 33 | $messaging = new Sendgrid('YOUR_API_KEY'); 34 | $messaging->send($message); 35 | 36 | $messaging = new Mailgun('YOUR_API_KEY', 'YOUR_DOMAIN'); 37 | $messaging->send($message); 38 | ``` 39 | 40 | ## SMS 41 | 42 | ```php 43 | send($message); 56 | 57 | $messaging = new Telesign('YOUR_USERNAME', 'YOUR_PASSWORD'); 58 | $messaging->send($message); 59 | ``` 60 | 61 | ## Push 62 | 63 | ```php 64 | send($message); 76 | ``` 77 | 78 | ## Adapters 79 | 80 | > Want to implement any of the missing adapters or have an idea for another? We would love to hear from you! Please check out our [contribution guide](./CONTRIBUTING.md) and [new adapter guide](./docs/add-new-adapter.md) for more information. 81 | 82 | ### Email 83 | - [x] [SendGrid](https://sendgrid.com/) 84 | - [x] [Mailgun](https://www.mailgun.com/) 85 | - [ ] [Mailjet](https://www.mailjet.com/) 86 | - [ ] [Mailchimp](https://www.mailchimp.com/) 87 | - [ ] [Postmark](https://postmarkapp.com/) 88 | - [ ] [SparkPost](https://www.sparkpost.com/) 89 | - [ ] [SendinBlue](https://www.sendinblue.com/) 90 | - [ ] [MailSlurp](https://www.mailslurp.com/) 91 | - [ ] [ElasticEmail](https://elasticemail.com/) 92 | - [ ] [SES](https://aws.amazon.com/ses/) 93 | 94 | ### SMS 95 | - [x] [Twilio](https://www.twilio.com/) 96 | - [x] [Twilio Notify](https://www.twilio.com/notify) 97 | - [x] [Telesign](https://www.telesign.com/) 98 | - [x] [Textmagic](https://www.textmagic.com/) 99 | - [x] [Msg91](https://msg91.com/) 100 | - [x] [Vonage](https://www.vonage.com/) 101 | - [x] [Plivo](https://www.plivo.com/) 102 | - [x] [Infobip](https://www.infobip.com/) 103 | - [x] [Clickatell](https://www.clickatell.com/) 104 | - [ ] [AfricasTalking](https://africastalking.com/) 105 | - [x] [Sinch](https://www.sinch.com/) 106 | - [x] [Seven](https://www.seven.io/) 107 | - [ ] [SmsGlobal](https://www.smsglobal.com/) 108 | - [x] [Inforu](https://www.inforu.co.il/) 109 | 110 | ### Push 111 | - [x] [FCM](https://firebase.google.com/docs/cloud-messaging) 112 | - [x] [APNS](https://developer.apple.com/documentation/usernotifications) 113 | - [ ] [OneSignal](https://onesignal.com/) 114 | - [ ] [Pusher](https://pusher.com/) 115 | - [ ] [WebPush](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) 116 | - [ ] [UrbanAirship](https://www.urbanairship.com/) 117 | - [ ] [Pushwoosh](https://www.pushwoosh.com/) 118 | - [ ] [PushBullet](https://www.pushbullet.com/) 119 | - [ ] [Pushy](https://pushy.me/) 120 | 121 | ## System Requirements 122 | 123 | Utopia Messaging requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. 124 | 125 | ## Tests 126 | 127 | To run all unit tests, use the following Docker command: 128 | 129 | ```bash 130 | composer test 131 | ``` 132 | 133 | To run static code analysis, use the following Psalm command: 134 | 135 | ```bash 136 | composer lint 137 | ``` 138 | 139 | ## Copyright and license 140 | 141 | The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) 142 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utopia-php/messaging", 3 | "description": "A simple, light and advanced PHP messaging library", 4 | "type": "library", 5 | "keywords": ["php","messaging", "upf", "utopia", "utopia-php", "library"], 6 | "license": "MIT", 7 | "minimum-stability": "stable", 8 | "scripts": { 9 | "test": "./vendor/bin/phpunit", 10 | "lint": "./vendor/bin/pint --preset psr12 --test", 11 | "format": "./vendor/bin/pint --preset psr12", 12 | "analyse": "./vendor/bin/phpstan analyse --memory-limit=2G --level=6 src tests" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "Utopia\\Messaging\\": "src/Utopia/Messaging" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "Utopia\\Tests\\": "tests/Messaging" 22 | } 23 | }, 24 | "require": { 25 | "php": ">=8.0.0", 26 | "ext-curl": "*", 27 | "ext-openssl": "*", 28 | "phpmailer/phpmailer": "6.9.1", 29 | "giggsey/libphonenumber-for-php-lite": "8.13.36" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "11.*", 33 | "laravel/pint": "1.*", 34 | "phpstan/phpstan": "1.*" 35 | }, 36 | "config": { 37 | "platform": { 38 | "php": "8.3" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tests: 3 | build: 4 | context: . 5 | volumes: 6 | - ./src:/usr/local/src/src 7 | - ./tests:/usr/local/src/tests 8 | - ./phpunit.xml:/usr/local/src/phpunit.xml 9 | environment: 10 | - MAILGUN_API_KEY 11 | - MAILGUN_DOMAIN 12 | - SENDGRID_API_KEY 13 | - FCM_SERVICE_ACCOUNT_JSON 14 | - FCM_TO 15 | - TWILIO_ACCOUNT_SID 16 | - TWILIO_AUTH_TOKEN 17 | - TWILIO_TO 18 | - TWILIO_FROM 19 | - TELNYX_API_KEY 20 | - TELNYX_PUBLIC_KEY 21 | - APNS_AUTHKEY_8KVVCLA3HL 22 | - APNS_AUTH_ID 23 | - APNS_TEAM_ID 24 | - APNS_BUNDLE_ID 25 | - APNS_TO 26 | - MSG_91_SENDER_ID 27 | - MSG_91_AUTH_KEY 28 | - MSG_91_TO 29 | - MSG_91_FROM 30 | - TEST_EMAIL 31 | - TEST_FROM_EMAIL 32 | - TEST_CC_EMAIL 33 | - TEST_BCC_EMAIL 34 | - TEST_BCC_NAME 35 | - VONAGE_API_KEY 36 | - VONAGE_API_SECRET 37 | - VONAGE_TO 38 | - VONAGE_FROM 39 | - DISCORD_WEBHOOK_URL 40 | - FAST2SMS_API_KEY 41 | - FAST2SMS_SENDER_ID 42 | - FAST2SMS_MESSAGE_ID 43 | - FAST2SMS_TO 44 | - INFORU_API_TOKEN 45 | - INFORU_SENDER_ID 46 | 47 | maildev: 48 | image: appwrite/mailcatcher:1.0.0 49 | ports: 50 | - '10000:1080' 51 | 52 | request-catcher: 53 | image: appwrite/requestcatcher:1.0.0 54 | ports: 55 | - '10001:5000' -------------------------------------------------------------------------------- /docs/add-new-adapter.md: -------------------------------------------------------------------------------- 1 | # Adding A New Messaging Adapter 2 | 3 | This document is a part of Utopia PHP contributors guide. Before you continue reading this document make sure you have read the [Code of Conduct](../CODE_OF_CONDUCT.md) and the [Contribution Guide](../CONTRIBUTING.md). 4 | 5 | ## Getting Started 6 | 7 | Messaging adapter allow utilization of different messaging services in a consistent way. This document will guide you through the process of adding a new messaging adapter to the Utopia PHP Messaging library. 8 | 9 | ## 1. Prerequisites 10 | 11 | It's really easy to contribute to an open source project, but when using GitHub, there are a few steps we need to follow. This section will take you step-by-step through the process of preparing your own local version of `utopia-php/messaging`, where you can make any changes without affecting the upstream repository right away. 12 | 13 | > If you are experienced with GitHub or have made a pull request before, you can skip to [Implement A New Messaging Adapter](#2-implement-new-messaging-adapter). 14 | 15 | ### 1.1 Fork The Repository 16 | 17 | Before making any changes, you will need to fork the `utopia-php/messaging` repository to keep branches on the upstream repo clean. To do that, visit the [repository](https://github.com/utopia-php/messaging) and click the **Fork** button. 18 | 19 | This will redirect you from `github.com/utopia-php/messaging` to `github.com/YOUR_USERNAME/messaging`, meaning all further changes will reflect on your copy of the repository. Once you are there, click the highlighted `Code` button, copy the URL and clone the repository to your computer using either a Git UI or the `git clone` command: 20 | 21 | ```shell 22 | $ git clone COPIED_URL 23 | ``` 24 | 25 | > To fork a repository, you will need the git-cli binaries installed and a basic understanding of CLI. If you are a beginner, we recommend you to use `Github Desktop`. It is a clean and simple visual Git client. 26 | 27 | Finally, you will need to create a `feat-XXX-YYY-messaging-adapter` branch based on the `master` branch and switch to it. The `XXX` should represent the issue ID and `YYY` the Storage adapter name. 28 | 29 | ## 2. Implement A New Messaging Adapter 30 | 31 | In order to start implementing a new messaging adapter, add new file inside `src/Utopia/Messaging/Adapters/XXX/YYY.php` where `XXX` is the type of adapter (**Email**, **SMS** or **Push**), and `YYY` is the name of the messaging provider in `PascalCase` casing. Inside the file you should create a class that extends the base `Email`, `SMS` or `Push` abstract adapter class. 32 | 33 | Inside the class, you need to implement four methods: 34 | 35 | - `__construct()` - This method should accept all the required parameters for the adapter to work. For example, the `SendGrid` adapter requires an API key, so the constructor should look like this: 36 | 37 | ```php 38 | public function __construct(private string $apiKey) 39 | ``` 40 | 41 | - `getName()` - This method should return the display name of the adapter. For example, the `SendGrid` adapter should return `SendGrid`: 42 | 43 | ```php 44 | public function getName(): string 45 | { 46 | return 'SendGrid'; 47 | } 48 | ``` 49 | 50 | - `getMaxMessagesPerRequest()` - This method should return the maximum number of messages that can be sent in a single request. For example, `SendGrid` can send 1000 messages per request, so this method should return 1000: 51 | 52 | ```php 53 | public function getMaxMessagesPerRequest(): int 54 | { 55 | return 1000; 56 | } 57 | ``` 58 | 59 | - `process()` - This method should accept a message object of the same type as the base adapter, and send it to the messaging provider, returning the response as a string. For example, the `SendGrid` adapter should accept an `Email` message object and send it to the SendGrid API: 60 | 61 | ```php 62 | public function process(Email $message): string 63 | { 64 | // Send message to SendGrid API 65 | } 66 | ``` 67 | 68 | The base `Adapter` class includes a two helper functions called `request()` and `requestMulti()` that can be used to send HTTP requests to the messaging provider. 69 | 70 | The `request()` function will send a single request and accepts the following parameters: 71 | 72 | - `method` - The HTTP method to use. For example, `POST`, `GET`, `PUT`, `PATCH` or `DELETE`. 73 | - `url` - The URL to send the request to. 74 | - `headers` - An array of headers to send with the request. 75 | - `body` - The body of the request as a string, or null if no body is required. 76 | - `timeout` - The timeout in seconds for the request. 77 | 78 | The `requestMulti()` function will send multiple concurrent requests via HTTP/2 multiplexing, and accepts the following parameters: 79 | 80 | - `method` - The HTTP method to use. For example, `POST`, `GET`, `PUT`, `PATCH` or `DELETE`. 81 | - `urls` - An array of URLs to send the requests to. 82 | - `headers` - An array of headers to send with the requests. 83 | - `bodies` - An array of bodies of the requests as strings, or an empty array if no body is required. 84 | - `timeout` - The timeout in seconds for the requests. 85 | 86 | `urls` and `bodies` must either be the same length, or one of them must contain only a single element. If `urls` contains only a single element, it will be used for all requests. If `bodies` contains only a single element, it will be used for all requests. 87 | 88 | The default content type of the request is `x-www-form-urlencoded`, but you can change it by adding a `Content-Type` header. No encoding is applied to the body, so you need to make sure it is encoded properly before sending the request. 89 | 90 | Putting it all together, the `SendGrid` adapter should look like this: 91 | 92 | ### Full Example 93 | 94 | ```php 95 | request( 121 | method: 'POST', 122 | url: 'https://api.sendgrid.com/v3/mail/send', 123 | headers: [ 124 | 'Content-Type: application/json', 125 | 'Authorization: Bearer ' . $this->apiKey, 126 | ], 127 | body: \json_encode([ 128 | 'personalizations' => [ 129 | [ 130 | 'to' => \array_map( 131 | fn($to) => ['email' => $to], 132 | $message->getTo() 133 | ), 134 | 'subject' => $message->getSubject(), 135 | ], 136 | ], 137 | 'from' => [ 138 | 'email' => $message->getFrom(), 139 | ], 140 | 'content' => [ 141 | [ 142 | 'type' => $message->isHtml() ? 'text/html' : 'text/plain', 143 | 'value' => $message->getContent(), 144 | ], 145 | ], 146 | ]), 147 | ); 148 | } 149 | } 150 | ``` 151 | 152 | ## 3. Test your adapter 153 | 154 | After you finish adding your new adapter, you need to ensure that it is usable. Use your newly created adapter to make some sample requests to your messaging service. 155 | 156 | If everything goes well, raise a pull request and be ready to respond to any feedback which can arise during code review. 157 | 158 | ## 4. Raise a pull request 159 | 160 | First of all, commit the changes with the message `Added YYY adapter` and push it. This will publish a new branch to your forked version of `utopia-php/messaging`. If you visit it at `github.com/YOUR_USERNAME/messaging`, you will see a new alert saying you are ready to submit a pull request. Follow the steps GitHub provides, and at the end, you will have your pull request submitted. 161 | 162 | ## 🤕 Stuck ? 163 | If you need any help with the contribution, feel free to head over to [our discord channel](https://appwrite.io/discord) and we'll be happy to help you out. 164 | -------------------------------------------------------------------------------- /docs/sms-pricing.md: -------------------------------------------------------------------------------- 1 | # SMS Provider pricing 2 | 3 | ### In case you wonder which provider will suite you with best price 4 | 5 | - _The list includes only implemented providers_ 6 | - _The list is sorted by unit price low -> high_ 7 | 8 | ## SMS Target 9 | 10 | _You can add your country with a full pricing table._ 11 | 12 | - [United States](#united-states) 13 | - [India](#india) 14 | - [Israel](#israel) 15 | 16 | ## United States 17 | 18 | _In most providers when you're sending SMS to US numbers you must own a US phone number to provide it in the `from` field. Other might add random phone number they have_ 19 | 20 | | Provider | Unit Price | 10K | 21 | |------------|------------|-------| 22 | | Plivo | 0.0050 | 50 $ | 23 | | Telesign | 0.0062 | 62 $ | 24 | | Msg91 | 0.0067 | 67 $ | 25 | | Vonage | 0.0067 | 67 $ | 26 | | Infobip | 0.0070 | 70 $ | 27 | | Clickatell | 0.0075 | 75 $ | 28 | | Twilio | 0.0079 | 79 $ | 29 | | Sinch | 0.0120 | 120 $ | 30 | | Textmagic | 0.0400 | 400 $ | 31 | | Telnyx | 0.0440 | 440 $ | 32 | | Seven | 0.0810 | 810 $ | 33 | 34 | ## India 35 | 36 | | Provider | Unit Price | 10K | 37 | |------------|------------|-------| 38 | | Msg91 | 0.0030 | 30 $ | 39 | | Clickatell | 0.0426 | 426 $ | 40 | | Vonage | 0.0449 | 449 $ | 41 | | Telesign | 0.0485 | 485 $ | 42 | | Telnyx | 0.0490 | 490 $ | 43 | | Twilio | 0.0490 | 490 $ | 44 | | Plivo | 0.0560 | 560 $ | 45 | | Seven | 0.0810 | 810 $ | 46 | | Infobip | n/a | | 47 | | Sinch | n/a | | 48 | | Textmagic | n/a | | 49 | 50 | ## Israel 51 | 52 | | Provider | Unit Price | 10K | 53 | |------------|------------|----------| 54 | | Telesign | 0.0768 | 768 $ | 55 | | Seven | 0.0810 | 810 $ | 56 | | Msg91 | 0.0845 | 845 $ | 57 | | Plivo | 0.101 | 1010 $ | 58 | | Vonage | 0.1019 | 1019 $ | 59 | | Infobip | 0.106 | 1060 $ | 60 | | Telnyx | 0.1100 | 1100 $ | 61 | | Twilio | 0.112 | 1120 $ | 62 | | Clickatell | 0.13144 | 1314.4 $ | 63 | | Sinch | n/a | | 64 | | Textmagic | n/a | | 65 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ./src 5 | ./tests 6 | 7 | 8 | 9 | * 10 | 11 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter.php: -------------------------------------------------------------------------------- 1 | > 37 | * } | array> 41 | * }> GEOSMS adapter returns an array of results keyed by adapter name. 42 | * 43 | * @throws \Exception 44 | */ 45 | public function send(Message $message): array 46 | { 47 | if (!\is_a($message, $this->getMessageType())) { 48 | throw new \Exception('Invalid message type.'); 49 | } 50 | if (\method_exists($message, 'getTo') && \count($message->getTo()) > $this->getMaxMessagesPerRequest()) { 51 | throw new \Exception("{$this->getName()} can only send {$this->getMaxMessagesPerRequest()} messages per request."); 52 | } 53 | if (!\method_exists($this, 'process')) { 54 | throw new \Exception('Adapter does not implement process method.'); 55 | } 56 | 57 | return $this->process($message); 58 | } 59 | 60 | /** 61 | * Send a single HTTP request. 62 | * 63 | * @param string $method The HTTP method to use. 64 | * @param string $url The URL to send the request to. 65 | * @param array $headers An array of headers to send with the request. 66 | * @param array|null $body The body of the request. 67 | * @param int $timeout The timeout in seconds. 68 | * @return array{ 69 | * url: string, 70 | * statusCode: int, 71 | * response: array|string|null, 72 | * error: string|null 73 | * } 74 | * 75 | * @throws \Exception If the request fails. 76 | */ 77 | protected function request( 78 | string $method, 79 | string $url, 80 | array $headers = [], 81 | array $body = null, 82 | int $timeout = 30 83 | ): array { 84 | $ch = \curl_init(); 85 | 86 | foreach ($headers as $header) { 87 | if (\str_contains($header, 'application/json')) { 88 | $body = \json_encode($body); 89 | break; 90 | } 91 | if (\str_contains($header, 'application/x-www-form-urlencoded')) { 92 | $body = \http_build_query($body); 93 | break; 94 | } 95 | } 96 | 97 | if (!\is_null($body)) { 98 | \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 99 | 100 | if (\is_string($body)) { 101 | $headers[] = 'Content-Length: '.\strlen($body); 102 | } 103 | } 104 | 105 | \curl_setopt_array($ch, [ 106 | CURLOPT_CUSTOMREQUEST => $method, 107 | CURLOPT_URL => $url, 108 | CURLOPT_HTTPHEADER => $headers, 109 | CURLOPT_RETURNTRANSFER => true, 110 | CURLOPT_USERAGENT => "Appwrite {$this->getName()} Message Sender", 111 | CURLOPT_TIMEOUT => $timeout, 112 | ]); 113 | 114 | $response = \curl_exec($ch); 115 | 116 | \curl_close($ch); 117 | 118 | try { 119 | $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); 120 | } catch (\JsonException) { 121 | // Ignore 122 | } 123 | 124 | return [ 125 | 'url' => $url, 126 | 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 127 | 'response' => $response, 128 | 'error' => \curl_error($ch), 129 | ]; 130 | } 131 | 132 | /** 133 | * Send multiple concurrent HTTP requests using HTTP/2 multiplexing. 134 | * 135 | * @param array $urls 136 | * @param array $headers 137 | * @param array> $bodies 138 | * @return array|null, 143 | * error: string|null 144 | * }> 145 | * 146 | * @throws Exception 147 | */ 148 | protected function requestMulti( 149 | string $method, 150 | array $urls, 151 | array $headers = [], 152 | array $bodies = [], 153 | int $timeout = 30 154 | ): array { 155 | if (empty($urls)) { 156 | throw new \Exception('No URLs provided. Must provide at least one URL.'); 157 | } 158 | 159 | foreach ($headers as $header) { 160 | if (\str_contains($header, 'application/json')) { 161 | foreach ($bodies as $i => $body) { 162 | $bodies[$i] = \json_encode($body); 163 | } 164 | break; 165 | } 166 | if (\str_contains($header, 'application/x-www-form-urlencoded')) { 167 | foreach ($bodies as $i => $body) { 168 | $bodies[$i] = \http_build_query($body); 169 | } 170 | break; 171 | } 172 | } 173 | 174 | $sh = \curl_share_init(); 175 | $mh = \curl_multi_init(); 176 | $ch = \curl_init(); 177 | 178 | \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); 179 | \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); 180 | 181 | \curl_setopt_array($ch, [ 182 | CURLOPT_SHARE => $sh, 183 | CURLOPT_CUSTOMREQUEST => $method, 184 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, 185 | CURLOPT_HTTPHEADER => $headers, 186 | CURLOPT_RETURNTRANSFER => true, 187 | CURLOPT_FORBID_REUSE => false, 188 | CURLOPT_FRESH_CONNECT => false, 189 | CURLOPT_TIMEOUT => $timeout, 190 | ]); 191 | 192 | $urlCount = \count($urls); 193 | $bodyCount = \count($bodies); 194 | 195 | if (!($urlCount == $bodyCount || $urlCount == 1 || $bodyCount == 1)) { 196 | throw new \Exception('URL and body counts must be equal or one must equal 1.'); 197 | } 198 | 199 | if ($urlCount > $bodyCount) { 200 | $bodies = \array_pad($bodies, $urlCount, $bodies[0]); 201 | } elseif ($urlCount < $bodyCount) { 202 | $urls = \array_pad($urls, $bodyCount, $urls[0]); 203 | } 204 | 205 | for ($i = 0; $i < \count($urls); $i++) { 206 | if (!empty($bodies[$i])) { 207 | $headers[] = 'Content-Length: '.\strlen($bodies[$i]); 208 | } 209 | 210 | \curl_setopt($ch, CURLOPT_URL, $urls[$i]); 211 | \curl_setopt($ch, CURLOPT_POSTFIELDS, $bodies[$i]); 212 | \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 213 | \curl_setopt($ch, CURLOPT_PRIVATE, $i); 214 | \curl_multi_add_handle($mh, \curl_copy_handle($ch)); 215 | } 216 | 217 | $active = true; 218 | do { 219 | $status = \curl_multi_exec($mh, $active); 220 | 221 | if ($active) { 222 | \curl_multi_select($mh); 223 | } 224 | } while ($active && $status == CURLM_OK); 225 | 226 | $responses = []; 227 | 228 | // Check each handle's result 229 | while ($info = \curl_multi_info_read($mh)) { 230 | $ch = $info['handle']; 231 | 232 | $response = \curl_multi_getcontent($ch); 233 | 234 | try { 235 | $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); 236 | } catch (\JsonException) { 237 | // Ignore 238 | } 239 | 240 | $responses[] = [ 241 | 'index' => (int)\curl_getinfo($ch, CURLINFO_PRIVATE), 242 | 'url' => \curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), 243 | 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 244 | 'response' => $response, 245 | 'error' => \curl_error($ch), 246 | ]; 247 | 248 | \curl_multi_remove_handle($mh, $ch); 249 | \curl_close($ch); 250 | } 251 | 252 | \curl_multi_close($mh); 253 | \curl_share_close($sh); 254 | 255 | return $responses; 256 | } 257 | 258 | 259 | /** 260 | * @param string $phone 261 | * @return int|null 262 | * @throws Exception 263 | */ 264 | public function getCountryCode(string $phone): ?int 265 | { 266 | if (empty($phone)) { 267 | throw new Exception('$phone cannot be empty.'); 268 | } 269 | 270 | $helper = PhoneNumberUtil::getInstance(); 271 | 272 | try { 273 | return $helper 274 | ->parse($phone) 275 | ->getCountryCode(); 276 | 277 | } catch (\Throwable $th) { 278 | throw new Exception("Error parsing phone: " . $th->getMessage()); 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Chat/Discord.php: -------------------------------------------------------------------------------- 1 | = 2) { 43 | $webhookParts = explode('/', $parts[1]); 44 | $this->webhookId = $webhookParts[0]; 45 | } 46 | if (empty($this->webhookId)) { 47 | throw new InvalidArgumentException('Discord webhook ID cannot be empty.'); 48 | } 49 | } 50 | 51 | public function getName(): string 52 | { 53 | return static::NAME; 54 | } 55 | 56 | public function getType(): string 57 | { 58 | return static::TYPE; 59 | } 60 | 61 | public function getMessageType(): string 62 | { 63 | return static::MESSAGE_TYPE; 64 | } 65 | 66 | public function getMaxMessagesPerRequest(): int 67 | { 68 | return 1; 69 | } 70 | 71 | /** 72 | * @return array{deliveredTo: int, type: string, results: array>} 73 | * 74 | * @throws \Exception 75 | */ 76 | protected function process(DiscordMessage $message): array 77 | { 78 | $query = []; 79 | 80 | if (!\is_null($message->getWait())) { 81 | $query['wait'] = $message->getWait(); 82 | } 83 | if (!\is_null($message->getThreadId())) { 84 | $query['thread_id'] = $message->getThreadId(); 85 | } 86 | 87 | $queryString = ''; 88 | foreach ($query as $key => $value) { 89 | if (empty($queryString)) { 90 | $queryString .= '?'; 91 | } 92 | $queryString .= $key.'='.$value; 93 | } 94 | 95 | $response = new Response($this->getType()); 96 | $result = $this->request( 97 | method: 'POST', 98 | url: "{$this->webhookURL}{$queryString}", 99 | headers: [ 100 | 'Content-Type: application/json', 101 | ], 102 | body: [ 103 | 'content' => $message->getContent(), 104 | 'username' => $message->getUsername(), 105 | 'avatar_url' => $message->getAvatarUrl(), 106 | 'tts' => $message->getTTS(), 107 | 'embeds' => $message->getEmbeds(), 108 | 'allowed_mentions' => $message->getAllowedMentions(), 109 | 'components' => $message->getComponents(), 110 | 'attachments' => $message->getAttachments(), 111 | 'flags' => $message->getFlags(), 112 | 'thread_name' => $message->getThreadName(), 113 | ], 114 | ); 115 | 116 | $statusCode = $result['statusCode']; 117 | 118 | if ($statusCode >= 200 && $statusCode < 300) { 119 | $response->setDeliveredTo(1); 120 | $response->addResult($this->webhookId); 121 | } elseif ($statusCode >= 400 && $statusCode < 500) { 122 | $response->addResult($this->webhookId, 'Bad Request.'); 123 | } else { 124 | $response->addResult($this->webhookId, 'Unknown Error.'); 125 | } 126 | 127 | return $response->toArray(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Email.php: -------------------------------------------------------------------------------- 1 | >} 29 | * 30 | * @throws \Exception 31 | */ 32 | abstract protected function process(EmailMessage $message): array; 33 | } 34 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Email/Mailgun.php: -------------------------------------------------------------------------------- 1 | isEU ? $euDomain : $usDomain; 53 | 54 | $body = [ 55 | 'to' => \implode(',', $message->getTo()), 56 | 'from' => "{$message->getFromName()}<{$message->getFromEmail()}>", 57 | 'subject' => $message->getSubject(), 58 | 'text' => $message->isHtml() ? null : $message->getContent(), 59 | 'html' => $message->isHtml() ? $message->getContent() : null, 60 | 'h:Reply-To: '."{$message->getReplyToName()}<{$message->getReplyToEmail()}>", 61 | ]; 62 | 63 | if (\count($message->getTo()) > 1) { 64 | $body['recipient-variables'] = json_encode(array_fill_keys($message->getTo(), [])); 65 | } 66 | 67 | if (!\is_null($message->getCC())) { 68 | foreach ($message->getCC() as $cc) { 69 | if (!empty($cc['email'])) { 70 | $ccString = !empty($cc['name']) 71 | ? "{$cc['name']}<{$cc['email']}>" 72 | : $cc['email']; 73 | 74 | $body['cc'] = !empty($body['cc']) 75 | ? "{$body['cc']},{$ccString}" 76 | : $ccString; 77 | } 78 | } 79 | } 80 | 81 | if (!\is_null($message->getBCC())) { 82 | foreach ($message->getBCC() as $bcc) { 83 | if (!empty($bcc['email'])) { 84 | $bccString = !empty($bcc['name']) 85 | ? "{$bcc['name']}<{$bcc['email']}>" 86 | : $bcc['email']; 87 | 88 | $body['bcc'] = !empty($body['bcc']) 89 | ? "{$body['bcc']},{$bccString}" 90 | : $bccString; 91 | } 92 | } 93 | } 94 | 95 | $isMultipart = false; 96 | 97 | if (!\is_null($message->getAttachments())) { 98 | $size = 0; 99 | 100 | foreach ($message->getAttachments() as $attachment) { 101 | $size += \filesize($attachment->getPath()); 102 | } 103 | 104 | if ($size > self::MAX_ATTACHMENT_BYTES) { 105 | throw new \Exception('Attachments size exceeds the maximum allowed size of '); 106 | } 107 | 108 | foreach ($message->getAttachments() as $index => $attachment) { 109 | $isMultipart = true; 110 | 111 | $body["attachment[$index]"] = \curl_file_create( 112 | $attachment->getPath(), 113 | $attachment->getType(), 114 | $attachment->getName(), 115 | ); 116 | } 117 | } 118 | 119 | $response = new Response($this->getType()); 120 | 121 | $headers = [ 122 | 'Authorization: Basic ' . \base64_encode("api:$this->apiKey"), 123 | ]; 124 | 125 | if ($isMultipart) { 126 | $headers[] = 'Content-Type: multipart/form-data'; 127 | } else { 128 | $headers[] = 'Content-Type: application/x-www-form-urlencoded'; 129 | } 130 | 131 | $result = $this->request( 132 | method: 'POST', 133 | url: "https://$domain/v3/$this->domain/messages", 134 | headers: $headers, 135 | body: $body, 136 | ); 137 | 138 | $statusCode = $result['statusCode']; 139 | 140 | if ($statusCode >= 200 && $statusCode < 300) { 141 | $response->setDeliveredTo(\count($message->getTo())); 142 | foreach ($message->getTo() as $to) { 143 | $response->addResult($to); 144 | } 145 | } elseif ($statusCode >= 400 && $statusCode < 500) { 146 | foreach ($message->getTo() as $to) { 147 | if (\is_string($result['response'])) { 148 | $response->addResult($to, $result['response']); 149 | } elseif (isset($result['response']['message'])) { 150 | $response->addResult($to, $result['response']['message']); 151 | } else { 152 | $response->addResult($to, 'Unknown error'); 153 | } 154 | } 155 | } 156 | 157 | return $response->toArray(); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Email/Mock.php: -------------------------------------------------------------------------------- 1 | getType()); 31 | $mail = new PHPMailer(); 32 | $mail->isSMTP(); 33 | $mail->XMailer = 'Utopia Mailer'; 34 | $mail->Host = 'maildev'; 35 | $mail->Port = 1025; 36 | $mail->SMTPAuth = false; 37 | $mail->Username = ''; 38 | $mail->Password = ''; 39 | $mail->SMTPSecure = ''; 40 | $mail->SMTPAutoTLS = false; 41 | $mail->CharSet = 'UTF-8'; 42 | $mail->Subject = $message->getSubject(); 43 | $mail->Body = $message->getContent(); 44 | $mail->AltBody = \strip_tags($message->getContent()); 45 | $mail->setFrom($message->getFromEmail(), $message->getFromName()); 46 | $mail->addReplyTo($message->getReplyToEmail(), $message->getReplyToName()); 47 | $mail->isHTML($message->isHtml()); 48 | 49 | foreach ($message->getTo() as $to) { 50 | $mail->addAddress($to); 51 | } 52 | 53 | if (!empty($message->getCC())) { 54 | foreach ($message->getCC() as $cc) { 55 | $mail->addCC($cc['email'], $cc['name'] ?? ''); 56 | } 57 | } 58 | 59 | if (!empty($message->getBCC())) { 60 | foreach ($message->getBCC() as $bcc) { 61 | $mail->addBCC($bcc['email'], $bcc['name'] ?? ''); 62 | } 63 | } 64 | 65 | if (!$mail->send()) { 66 | foreach ($message->getTo() as $to) { 67 | $response->addResult($to, $mail->ErrorInfo); 68 | } 69 | } else { 70 | $response->setDeliveredTo(\count($message->getTo())); 71 | foreach ($message->getTo() as $to) { 72 | $response->addResult($to); 73 | } 74 | } 75 | 76 | return $response->toArray(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Email/SMTP.php: -------------------------------------------------------------------------------- 1 | smtpSecure, ['', 'ssl', 'tls'])) { 33 | throw new \InvalidArgumentException('Invalid SMTP secure prefix. Must be "", "ssl" or "tls"'); 34 | } 35 | } 36 | 37 | public function getName(): string 38 | { 39 | return static::NAME; 40 | } 41 | 42 | public function getMaxMessagesPerRequest(): int 43 | { 44 | return 1000; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | protected function process(EmailMessage $message): array 51 | { 52 | $response = new Response($this->getType()); 53 | $mail = new PHPMailer(); 54 | $mail->isSMTP(); 55 | $mail->XMailer = $this->xMailer; 56 | $mail->Host = $this->host; 57 | $mail->Port = $this->port; 58 | $mail->SMTPAuth = !empty($this->username) && !empty($this->password); 59 | $mail->Username = $this->username; 60 | $mail->Password = $this->password; 61 | $mail->SMTPSecure = $this->smtpSecure; 62 | $mail->SMTPAutoTLS = $this->smtpAutoTLS; 63 | $mail->CharSet = 'UTF-8'; 64 | $mail->Subject = $message->getSubject(); 65 | $mail->Body = $message->getContent(); 66 | $mail->setFrom($message->getFromEmail(), $message->getFromName()); 67 | $mail->addReplyTo($message->getReplyToEmail(), $message->getReplyToName()); 68 | $mail->isHTML($message->isHtml()); 69 | 70 | // Strip tags misses style tags, so we use regex to remove them 71 | $mail->AltBody = \preg_replace('/]*>(.*?)<\/style>/is', '', $mail->Body); 72 | $mail->AltBody = \strip_tags($mail->AltBody); 73 | $mail->AltBody = \trim($mail->AltBody); 74 | 75 | if (empty($message->getTo())) { 76 | if (empty($message->getBCC()) && empty($message->getDefaultRecipient())) { 77 | throw new \Exception('Email requires either "to" recipients or both BCC and a default recipient configurations'); 78 | } 79 | 80 | $mail->addAddress($message->getDefaultRecipient()); 81 | } 82 | 83 | foreach ($message->getTo() as $to) { 84 | $mail->addAddress($to); 85 | } 86 | 87 | if (!empty($message->getCC())) { 88 | foreach ($message->getCC() as $cc) { 89 | $mail->addCC($cc['email'], $cc['name'] ?? ''); 90 | } 91 | } 92 | 93 | if (!empty($message->getBCC())) { 94 | foreach ($message->getBCC() as $bcc) { 95 | $mail->addBCC($bcc['email'], $bcc['name'] ?? ''); 96 | } 97 | } 98 | 99 | if (!empty($message->getAttachments())) { 100 | $size = 0; 101 | 102 | foreach ($message->getAttachments() as $attachment) { 103 | $size += \filesize($attachment->getPath()); 104 | } 105 | 106 | if ($size > self::MAX_ATTACHMENT_BYTES) { 107 | throw new \Exception('Attachments size exceeds the maximum allowed size of 25MB'); 108 | } 109 | 110 | foreach ($message->getAttachments() as $attachment) { 111 | $mail->addStringAttachment( 112 | string: \file_get_contents($attachment->getPath()), 113 | filename: $attachment->getName(), 114 | type: $attachment->getType() 115 | ); 116 | } 117 | } 118 | 119 | $sent = $mail->send(); 120 | 121 | if ($sent) { 122 | $totalDelivered = \count($message->getTo()) + \count($message->getCC() ?: []) + \count($message->getBCC() ?: []); 123 | $response->setDeliveredTo($totalDelivered); 124 | } 125 | 126 | foreach ($message->getTo() as $to) { 127 | $error = empty($mail->ErrorInfo) 128 | ? 'Unknown error' 129 | : $mail->ErrorInfo; 130 | 131 | $response->addResult($to, $sent ? '' : $error); 132 | } 133 | 134 | foreach ($message->getCC() as $cc) { 135 | $error = empty($mail->ErrorInfo) 136 | ? 'Unknown error' 137 | : $mail->ErrorInfo; 138 | 139 | $response->addResult($cc['email'], $sent ? '' : $error); 140 | } 141 | 142 | foreach ($message->getBCC() as $bcc) { 143 | $error = empty($mail->ErrorInfo) 144 | ? 'Unknown error' 145 | : $mail->ErrorInfo; 146 | 147 | $response->addResult($bcc['email'], $sent ? '' : $error); 148 | } 149 | 150 | return $response->toArray(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Email/Sendgrid.php: -------------------------------------------------------------------------------- 1 | [ 48 | 'to' => [['email' => $to]], 49 | 'subject' => $message->getSubject(), 50 | ], 51 | $message->getTo() 52 | ); 53 | 54 | if (!empty($message->getCC())) { 55 | foreach ($personalizations as &$personalization) { 56 | foreach ($message->getCC() as $cc) { 57 | $entry = ['email' => $cc['email']]; 58 | if (!empty($cc['name'])) { 59 | $entry['name'] = $cc['name']; 60 | } 61 | $personalization['cc'][] = $entry; 62 | } 63 | } 64 | unset($personalization); 65 | } 66 | 67 | if (!empty($message->getBCC())) { 68 | foreach ($personalizations as &$personalization) { 69 | foreach ($message->getBCC() as $bcc) { 70 | $entry = ['email' => $bcc['email']]; 71 | if (!empty($bcc['name'])) { 72 | $entry['name'] = $bcc['name']; 73 | } 74 | $personalization['bcc'][] = $entry; 75 | } 76 | } 77 | unset($personalization); 78 | } 79 | 80 | $attachments = []; 81 | 82 | if (!\is_null($message->getAttachments())) { 83 | $size = 0; 84 | 85 | foreach ($message->getAttachments() as $attachment) { 86 | $size += \filesize($attachment->getPath()); 87 | } 88 | 89 | if ($size > self::MAX_ATTACHMENT_BYTES) { 90 | throw new \Exception('Attachments size exceeds the maximum allowed size of 25MB'); 91 | } 92 | 93 | foreach ($message->getAttachments() as $attachment) { 94 | $attachments[] = [ 95 | 'content' => \base64_encode(\file_get_contents($attachment->getPath())), 96 | 'filename' => $attachment->getName(), 97 | 'type' => $attachment->getType(), 98 | 'disposition' => 'attachment', 99 | ]; 100 | } 101 | } 102 | 103 | $body = [ 104 | 'personalizations' => $personalizations, 105 | 'reply_to' => [ 106 | 'name' => $message->getReplyToName(), 107 | 'email' => $message->getReplyToEmail(), 108 | ], 109 | 'from' => [ 110 | 'name' => $message->getFromName(), 111 | 'email' => $message->getFromEmail(), 112 | ], 113 | 'content' => [ 114 | [ 115 | 'type' => $message->isHtml() ? 'text/html' : 'text/plain', 116 | 'value' => $message->getContent(), 117 | ], 118 | ], 119 | ]; 120 | 121 | if (!empty($attachments)) { 122 | $body['attachments'] = $attachments; 123 | } 124 | 125 | $response = new Response($this->getType()); 126 | $result = $this->request( 127 | method: 'POST', 128 | url: 'https://api.sendgrid.com/v3/mail/send', 129 | headers: [ 130 | 'Authorization: Bearer '.$this->apiKey, 131 | 'Content-Type: application/json', 132 | ], 133 | body: $body, 134 | ); 135 | 136 | $statusCode = $result['statusCode']; 137 | 138 | if ($statusCode === 202) { 139 | $response->setDeliveredTo(\count($message->getTo())); 140 | foreach ($message->getTo() as $to) { 141 | $response->addResult($to); 142 | } 143 | } else { 144 | foreach ($message->getTo() as $to) { 145 | if (\is_string($result['response'])) { 146 | $response->addResult($to, $result['response']); 147 | } elseif (!\is_null($result['response']['errors'][0]['message'] ?? null)) { 148 | $response->addResult($to, $result['response']['errors'][0]['message']); 149 | } else { 150 | $response->addResult($to, 'Unknown error'); 151 | } 152 | } 153 | } 154 | 155 | return $response->toArray(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Push.php: -------------------------------------------------------------------------------- 1 | >} 33 | * 34 | * @throws \Exception 35 | */ 36 | abstract protected function process(PushMessage $message): array; 37 | } 38 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Push/APNS.php: -------------------------------------------------------------------------------- 1 | getTitle())) { 51 | $payload['aps']['alert']['title'] = $message->getTitle(); 52 | } 53 | if (!\is_null($message->getBody())) { 54 | $payload['aps']['alert']['body'] = $message->getBody(); 55 | } 56 | if (!\is_null($message->getData())) { 57 | $payload['aps']['data'] = $message->getData(); 58 | } 59 | if (!\is_null($message->getAction())) { 60 | $payload['aps']['category'] = $message->getAction(); 61 | } 62 | if (!\is_null($message->getCritical())) { 63 | $payload['aps']['sound']['critical'] = 1; 64 | $payload['aps']['sound']['name'] = 'default'; 65 | $payload['aps']['sound']['volume'] = 1.0; 66 | } 67 | if (!\is_null($message->getSound())) { 68 | if (!\is_null($message->getCritical())) { 69 | $payload['aps']['sound']['name'] = $message->getSound(); 70 | } else { 71 | $payload['aps']['sound'] = $message->getSound(); 72 | } 73 | } 74 | if (!\is_null($message->getBadge())) { 75 | $payload['aps']['badge'] = $message->getBadge(); 76 | } 77 | if (!\is_null($message->getContentAvailable())) { 78 | $payload['aps']['content-available'] = (int)$message->getContentAvailable(); 79 | } 80 | if (!\is_null($message->getPriority())) { 81 | $payload['headers']['apns-priority'] = match ($message->getPriority()) { 82 | Priority::HIGH => '10', 83 | Priority::NORMAL => '5', 84 | }; 85 | } 86 | 87 | $claims = [ 88 | 'iss' => $this->teamId, // Issuer 89 | 'iat' => \time(), // Issued at time 90 | 'exp' => \time() + 3600, // Expiration time 91 | ]; 92 | 93 | $jwt = JWT::encode( 94 | $claims, 95 | $this->authKey, 96 | 'ES256', 97 | $this->authKeyId 98 | ); 99 | 100 | $endpoint = 'https://api.push.apple.com'; 101 | 102 | if ($this->sandbox) { 103 | $endpoint = 'https://api.development.push.apple.com'; 104 | } 105 | 106 | $urls = []; 107 | foreach ($message->getTo() as $token) { 108 | $urls[] = $endpoint.'/3/device/'.$token; 109 | } 110 | 111 | $results = $this->requestMulti( 112 | method: 'POST', 113 | urls: $urls, 114 | headers: [ 115 | 'Content-Type: application/json', 116 | 'Authorization: Bearer '.$jwt, 117 | 'apns-topic: '.$this->bundleId, 118 | 'apns-push-type: alert', 119 | ], 120 | bodies: [$payload] 121 | ); 122 | 123 | $response = new Response($this->getType()); 124 | 125 | foreach ($results as $result) { 126 | $device = \basename($result['url']); 127 | $statusCode = $result['statusCode']; 128 | 129 | switch ($statusCode) { 130 | case 200: 131 | $response->incrementDeliveredTo(); 132 | $response->addResult($device); 133 | break; 134 | default: 135 | $error = ($result['response']['reason'] ?? null) === 'ExpiredToken' || ($result['response']['reason'] ?? null) === 'BadDeviceToken' 136 | ? $this->getExpiredErrorMessage() 137 | : $result['response']['reason']; 138 | 139 | $response->addResult($device, $error); 140 | break; 141 | } 142 | } 143 | 144 | return $response->toArray(); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/Push/FCM.php: -------------------------------------------------------------------------------- 1 | serviceAccountJSON, true); 48 | 49 | $now = \time(); 50 | 51 | $signingKey = $credentials['private_key']; 52 | $signingAlgorithm = 'RS256'; 53 | 54 | $payload = [ 55 | 'iss' => $credentials['client_email'], 56 | 'exp' => $now + self::DEFAULT_EXPIRY_SECONDS, 57 | 'iat' => $now - self::DEFAULT_SKEW_SECONDS, 58 | 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 59 | 'aud' => self::GOOGLE_TOKEN_URL, 60 | ]; 61 | 62 | $jwt = JWT::encode( 63 | $payload, 64 | $signingKey, 65 | $signingAlgorithm, 66 | ); 67 | 68 | $signingKey = null; 69 | $payload = null; 70 | 71 | $token = $this->request( 72 | method: 'POST', 73 | url: self::GOOGLE_TOKEN_URL, 74 | headers: [ 75 | 'Content-Type: application/x-www-form-urlencoded', 76 | ], 77 | body: [ 78 | 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 79 | 'assertion' => $jwt, 80 | ] 81 | ); 82 | 83 | $accessToken = $token['response']['access_token']; 84 | 85 | $shared = []; 86 | 87 | if (!\is_null($message->getTitle())) { 88 | $shared['message']['notification']['title'] = $message->getTitle(); 89 | } 90 | if (!\is_null($message->getBody())) { 91 | $shared['message']['notification']['body'] = $message->getBody(); 92 | } 93 | if (!\is_null($message->getData())) { 94 | $shared['message']['data'] = $message->getData(); 95 | } 96 | if (!\is_null($message->getAction())) { 97 | $shared['message']['android']['notification']['click_action'] = $message->getAction(); 98 | $shared['message']['apns']['payload']['aps']['category'] = $message->getAction(); 99 | } 100 | if (!\is_null($message->getImage())) { 101 | $shared['message']['android']['notification']['image'] = $message->getImage(); 102 | $shared['message']['apns']['payload']['aps']['mutable-content'] = 1; 103 | $shared['message']['apns']['fcm_options']['image'] = $message->getImage(); 104 | } 105 | if (!\is_null($message->getCritical())) { 106 | $shared['message']['apns']['payload']['aps']['sound']['critical'] = 1; 107 | } 108 | if (!\is_null($message->getSound())) { 109 | $shared['message']['android']['notification']['sound'] = $message->getSound(); 110 | 111 | if (!\is_null($message->getCritical())) { 112 | $shared['message']['apns']['payload']['aps']['sound']['name'] = $message->getSound(); 113 | } else { 114 | $shared['message']['apns']['payload']['aps']['sound'] = $message->getSound(); 115 | } 116 | } 117 | if (!\is_null($message->getIcon())) { 118 | $shared['message']['android']['notification']['icon'] = $message->getIcon(); 119 | } 120 | if (!\is_null($message->getColor())) { 121 | $shared['message']['android']['notification']['color'] = $message->getColor(); 122 | } 123 | if (!\is_null($message->getTag())) { 124 | $shared['message']['android']['notification']['tag'] = $message->getTag(); 125 | } 126 | if (!\is_null($message->getBadge())) { 127 | $shared['message']['apns']['payload']['aps']['badge'] = $message->getBadge(); 128 | } 129 | if (!\is_null($message->getContentAvailable())) { 130 | $shared['message']['apns']['payload']['aps']['content-available'] = (int)$message->getContentAvailable(); 131 | } 132 | if (!\is_null($message->getPriority())) { 133 | $shared['message']['android']['priority'] = match ($message->getPriority()) { 134 | Priority::HIGH => 'high', 135 | Priority::NORMAL => 'normal' 136 | }; 137 | $shared['message']['apns']['headers']['apns-priority'] = match ($message->getPriority()) { 138 | Priority::HIGH => '10', 139 | Priority::NORMAL => '5', 140 | }; 141 | } 142 | 143 | $bodies = []; 144 | 145 | foreach ($message->getTo() as $to) { 146 | $body = $shared; 147 | $body['message']['token'] = $to; 148 | $bodies[] = $body; 149 | } 150 | 151 | $results = $this->requestMulti( 152 | method: 'POST', 153 | urls: ["https://fcm.googleapis.com/v1/projects/{$credentials['project_id']}/messages:send"], 154 | headers: [ 155 | 'Content-Type: application/json', 156 | "Authorization: Bearer {$accessToken}", 157 | ], 158 | bodies: $bodies 159 | ); 160 | 161 | $response = new Response($this->getType()); 162 | 163 | foreach ($results as $result) { 164 | if ($result['statusCode'] === 200) { 165 | $response->incrementDeliveredTo(); 166 | $response->addResult($message->getTo()[$result['index']]); 167 | } else { 168 | $error = 169 | ($result['response']['error']['status'] ?? null) === 'UNREGISTERED' 170 | || ($result['response']['error']['status'] ?? null) === 'NOT_FOUND' 171 | ? $this->getExpiredErrorMessage() 172 | : $result['response']['error']['message'] ?? 'Unknown error'; 173 | 174 | $response->addResult($message->getTo()[$result['index']], $error); 175 | } 176 | } 177 | 178 | return $response->toArray(); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS.php: -------------------------------------------------------------------------------- 1 | >} 28 | * 29 | * @throws \Exception If the message fails. 30 | */ 31 | abstract protected function process(SMSMessage $message): array; 32 | } 33 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Clickatell.php: -------------------------------------------------------------------------------- 1 | getType()); 42 | 43 | $result = $this->request( 44 | method: 'POST', 45 | url: 'https://platform.clickatell.com/messages', 46 | headers: [ 47 | 'Content-Type: application/json', 48 | 'Authorization: '.$this->apiKey, 49 | ], 50 | body: [ 51 | 'content' => $message->getContent(), 52 | 'from' => $this->from ?? $message->getFrom(), 53 | 'to' => $message->getTo(), 54 | ], 55 | ); 56 | 57 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 58 | $response->setDeliveredTo(\count($message->getTo())); 59 | foreach ($message->getTo() as $to) { 60 | $response->addResult($to); 61 | } 62 | } else { 63 | foreach ($message->getTo() as $to) { 64 | $response->addResult($to, 'Unknown error.'); 65 | } 66 | } 67 | 68 | return $response->toArray(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Fast2SMS.php: -------------------------------------------------------------------------------- 1 | The response from the API 65 | */ 66 | protected function process(SMSMessage $message): array 67 | { 68 | $numbers = array_map( 69 | fn ($number) => $this->removeCountryCode($number), 70 | $message->getTo() 71 | ); 72 | $numbers = implode(',', $numbers); 73 | 74 | $payload = [ 75 | 'numbers' => $numbers, 76 | 'flash' => 0, 77 | ]; 78 | 79 | if ($this->useDLT) { 80 | $payload['route'] = 'dlt'; 81 | $payload['sender_id'] = $this->senderId; 82 | $payload['message'] = $this->messageId; 83 | $payload['variables_values'] = $message->getContent(); 84 | } else { 85 | $payload['route'] = 'q'; 86 | $payload['message'] = $message->getContent(); 87 | } 88 | 89 | $response = new Response($this->getType()); 90 | $result = $this->request( 91 | method: 'POST', 92 | url: self::API_ENDPOINT, 93 | headers: [ 94 | 'authorization: ' . $this->apiKey, 95 | 'Content-Type: application/json', 96 | 'Accept: application/json', 97 | ], 98 | body: $payload 99 | ); 100 | 101 | $res = $result['response']; 102 | if ($result['statusCode'] === 200 && isset($res['return']) && $res['return'] === true) { 103 | $response->setDeliveredTo(\count($message->getTo())); 104 | foreach ($message->getTo() as $to) { 105 | $response->addResult($to); 106 | } 107 | } else { 108 | $errorMessage = $res['message'] ?? 'Unknown error' . ' Status Code: ' . ($res['status_code'] ?? 'Unknown'); 109 | foreach ($message->getTo() as $to) { 110 | $response->addResult($to, $errorMessage); 111 | } 112 | } 113 | 114 | return $response->toArray(); 115 | } 116 | 117 | /** 118 | * Removes country code from a phone number 119 | * Fast2SMS expects Indian phone numbers without the country code 120 | * 121 | * @param string $number Phone number with potential country code 122 | * @return string Phone number without country code 123 | */ 124 | private function removeCountryCode(string $number): string 125 | { 126 | // Remove any non-digit characters 127 | $digits = preg_replace('/[^0-9]/', '', $number); 128 | 129 | $code = CallingCode::fromPhoneNumber($number); 130 | if ($code !== null) { 131 | return substr($digits, strlen($code)); 132 | } 133 | 134 | return $digits; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/GEOSMS.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | protected array $localAdapters = []; 19 | 20 | public function __construct(SMSAdapter $defaultAdapter) 21 | { 22 | $this->defaultAdapter = $defaultAdapter; 23 | } 24 | 25 | public function getName(): string 26 | { 27 | return static::NAME; 28 | } 29 | 30 | public function getMaxMessagesPerRequest(): int 31 | { 32 | return PHP_INT_MAX; 33 | } 34 | 35 | public function setLocal(string $callingCode, SMSAdapter $adapter): self 36 | { 37 | $this->localAdapters[$callingCode] = $adapter; 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | protected function filterCallingCodesByAdapter(SMSAdapter $adapter): array 46 | { 47 | $result = []; 48 | 49 | foreach ($this->localAdapters as $callingCode => $localAdapter) { 50 | if ($localAdapter === $adapter) { 51 | $result[] = $callingCode; 52 | } 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | /** 59 | * @return array>}> 60 | */ 61 | protected function process(SMS $message): array 62 | { 63 | $results = []; 64 | $recipients = $message->getTo(); 65 | 66 | do { 67 | [$nextRecipients, $nextAdapter] = $this->getNextRecipientsAndAdapter($recipients); 68 | 69 | try { 70 | $results[$nextAdapter->getName()] = $nextAdapter->send( 71 | new SMS( 72 | to: $nextRecipients, 73 | content: $message->getContent(), 74 | from: $message->getFrom(), 75 | attachments: $message->getAttachments() 76 | ) 77 | ); 78 | } catch (\Exception $e) { 79 | $results[$nextAdapter->getName()] = [ 80 | 'type' => 'error', 81 | 'message' => $e->getMessage(), 82 | ]; 83 | } 84 | 85 | $recipients = \array_diff($recipients, $nextRecipients); 86 | } while (count($recipients) > 0); 87 | 88 | return $results; 89 | } 90 | 91 | /** 92 | * @param array $recipients 93 | * @return array|SMSAdapter> 94 | */ 95 | protected function getNextRecipientsAndAdapter(array $recipients): array 96 | { 97 | $nextRecipients = []; 98 | $nextAdapter = null; 99 | 100 | foreach ($recipients as $recipient) { 101 | $adapter = $this->getAdapterByPhoneNumber($recipient); 102 | 103 | if ($nextAdapter === null || $adapter === $nextAdapter) { 104 | $nextAdapter = $adapter; 105 | $nextRecipients[] = $recipient; 106 | } 107 | } 108 | 109 | return [$nextRecipients, $nextAdapter]; 110 | } 111 | 112 | protected function getAdapterByPhoneNumber(?string $phoneNumber): SMSAdapter 113 | { 114 | $callingCode = CallingCode::fromPhoneNumber($phoneNumber); 115 | if (empty($callingCode)) { 116 | return $this->defaultAdapter; 117 | } 118 | 119 | if (isset($this->localAdapters[$callingCode])) { 120 | return $this->localAdapters[$callingCode]; 121 | } 122 | 123 | return $this->defaultAdapter; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/GEOSMS/CallingCode.php: -------------------------------------------------------------------------------- 1 | true, 388 | self::ANDORRA => true, 389 | self::ANGOLA => true, 390 | self::ARGENTINA => true, 391 | self::ARMENIA => true, 392 | self::ARUBA => true, 393 | self::AUSTRALIA => true, 394 | self::AUSTRIA => true, 395 | self::AZERBAIJAN => true, 396 | self::BAHRAIN => true, 397 | self::BANGLADESH => true, 398 | self::BELARUS => true, 399 | self::BELGIUM => true, 400 | self::BELIZE => true, 401 | self::BENIN => true, 402 | self::BHUTAN => true, 403 | self::BOLIVIA => true, 404 | self::BOSNIA_HERZEGOVINA => true, 405 | self::BOTSWANA => true, 406 | self::BRAZIL => true, 407 | self::BRUNEI => true, 408 | self::BULGARIA => true, 409 | self::BURKINA_FASO => true, 410 | self::BURUNDI => true, 411 | self::CAMBODIA => true, 412 | self::CAMEROON => true, 413 | self::CAPE_VERDE_ISLANDS => true, 414 | self::CENTRAL_AFRICAN_REPUBLIC => true, 415 | self::CHILE => true, 416 | self::CHINA => true, 417 | self::COLOMBIA => true, 418 | self::COMOROS_AND_MAYOTTE => true, 419 | self::CONGO => true, 420 | self::COOK_ISLANDS => true, 421 | self::COSTA_RICA => true, 422 | self::CROATIA => true, 423 | self::CUBA => true, 424 | self::CYPRUS => true, 425 | self::CZECH_REPUBLIC => true, 426 | self::DENMARK => true, 427 | self::DJIBOUTI => true, 428 | self::ECUADOR => true, 429 | self::EGYPT => true, 430 | self::EL_SALVADOR => true, 431 | self::EQUATORIAL_GUINEA => true, 432 | self::ERITREA => true, 433 | self::ESTONIA => true, 434 | self::ETHIOPIA => true, 435 | self::FALKLAND_ISLANDS => true, 436 | self::FAROE_ISLANDS => true, 437 | self::FIJI => true, 438 | self::FINLAND => true, 439 | self::FRANCE => true, 440 | self::FRENCH_GUIANA => true, 441 | self::FRENCH_POLYNESIA => true, 442 | self::GABON => true, 443 | self::GAMBIA => true, 444 | self::GEORGIA => true, 445 | self::GERMANY => true, 446 | self::GHANA => true, 447 | self::GIBRALTAR => true, 448 | self::GREECE => true, 449 | self::GREENLAND => true, 450 | self::GUADELOUPE => true, 451 | self::GUAM => true, 452 | self::GUATEMALA => true, 453 | self::GUINEA => true, 454 | self::GUINEA_BISSAU => true, 455 | self::GUYANA => true, 456 | self::HAITI => true, 457 | self::HONDURAS => true, 458 | self::HONG_KONG => true, 459 | self::HUNGARY => true, 460 | self::ICELAND => true, 461 | self::INDIA => true, 462 | self::INDONESIA => true, 463 | self::IRAN => true, 464 | self::IRAQ => true, 465 | self::IRELAND => true, 466 | self::ISRAEL => true, 467 | self::ITALY => true, 468 | self::JAPAN => true, 469 | self::JORDAN => true, 470 | self::KENYA => true, 471 | self::KIRIBATI => true, 472 | self::NORTH_KOREA => true, 473 | self::SOUTH_KOREA => true, 474 | self::KUWAIT => true, 475 | self::KYRGYZSTAN => true, 476 | self::LAOS => true, 477 | self::LATVIA => true, 478 | self::LEBANON => true, 479 | self::LESOTHO => true, 480 | self::LIBERIA => true, 481 | self::LIBYA => true, 482 | self::LIECHTENSTEIN => true, 483 | self::LITHUANIA => true, 484 | self::LUXEMBOURG => true, 485 | self::MACAO => true, 486 | self::MACEDONIA => true, 487 | self::MADAGASCAR => true, 488 | self::MALAWI => true, 489 | self::MALAYSIA => true, 490 | self::MALDIVES => true, 491 | self::MALI => true, 492 | self::MALTA => true, 493 | self::MARSHALL_ISLANDS => true, 494 | self::MARTINIQUE => true, 495 | self::MAURITANIA => true, 496 | self::MEXICO => true, 497 | self::MICRONESIA => true, 498 | self::MOLDOVA => true, 499 | self::MONACO => true, 500 | self::MONGOLIA => true, 501 | self::MOROCCO => true, 502 | self::MOZAMBIQUE => true, 503 | self::MYANMAR => true, 504 | self::NAMIBIA => true, 505 | self::NAURU => true, 506 | self::NEPAL => true, 507 | self::NETHERLANDS => true, 508 | self::NEW_CALEDONIA => true, 509 | self::NEW_ZEALAND => true, 510 | self::NICARAGUA => true, 511 | self::NIGER => true, 512 | self::NIGERIA => true, 513 | self::NIUE => true, 514 | self::NORFOLK_ISLANDS => true, 515 | self::NORTHERN_MARIANA_ISLANDS => true, 516 | self::NORWAY => true, 517 | self::OMAN => true, 518 | self::PALAU => true, 519 | self::PANAMA => true, 520 | self::PAPUA_NEW_GUINEA => true, 521 | self::PARAGUAY => true, 522 | self::PERU => true, 523 | self::PHILIPPINES => true, 524 | self::POLAND => true, 525 | self::PORTUGAL => true, 526 | self::QATAR => true, 527 | self::REUNION => true, 528 | self::ROMANIA => true, 529 | self::RUSSIA_KAZAKHSTAN_UZBEKISTAN_TURKMENISTAN_AND_TAJIKSTAN => true, 530 | self::RWANDA => true, 531 | self::SAN_MARINO => true, 532 | self::SAO_TOME_AND_PRINCIPE => true, 533 | self::SAUDI_ARABIA => true, 534 | self::SENEGAL => true, 535 | self::SERBIA => true, 536 | self::SEYCHELLES => true, 537 | self::SIERRA_LEONE => true, 538 | self::SINGAPORE => true, 539 | self::SLOVAK_REPUBLIC => true, 540 | self::SLOVENIA => true, 541 | self::SOLOMON_ISLANDS => true, 542 | self::SOMALIA => true, 543 | self::SOUTH_AFRICA => true, 544 | self::SPAIN => true, 545 | self::SRI_LANKA => true, 546 | self::ST_HELENA => true, 547 | self::SUDAN => true, 548 | self::SURINAME => true, 549 | self::SWAZILAND => true, 550 | self::SWEDEN => true, 551 | self::SWITZERLAND => true, 552 | self::SYRIA => true, 553 | self::TAIWAN => true, 554 | self::THAILAND => true, 555 | self::TOGO => true, 556 | self::TONGA => true, 557 | self::TUNISIA => true, 558 | self::TURKEY => true, 559 | self::TUVALU => true, 560 | self::UGANDA => true, 561 | self::UKRAINE => true, 562 | self::UNITED_ARAB_EMIRATES => true, 563 | self::UNITED_KINGDOM => true, 564 | self::URUGUAY => true, 565 | self::NORTH_AMERICA => true, 566 | self::VANUATU => true, 567 | self::VENEZUELA => true, 568 | self::VIETNAM => true, 569 | self::WALLIS_AND_FUTUNA => true, 570 | self::YEMEN => true, 571 | self::ZAMBIA => true, 572 | self::ZANZIBAR => true, 573 | self::ZIMBABWE => true, 574 | ]; 575 | 576 | public static function fromPhoneNumber(string $number): ?string 577 | { 578 | $digits = str_replace(['+', ' ', '(', ')', '-'], '', $number); 579 | 580 | // Remove international call prefix, usually `00` or `011` 581 | // https://en.wikipedia.org/wiki/List_of_international_call_prefixes 582 | $digits = preg_replace('/^00|^011/', '', $digits); 583 | 584 | // Prefixes can be 3, 2, or 1 digits long 585 | // Attempt to match the longest first 586 | foreach ([3, 2, 1] as $length) { 587 | $code = substr($digits, 0, $length); 588 | if (isset(self::CODES[$code])) { 589 | return $code; 590 | } 591 | } 592 | 593 | return null; 594 | } 595 | } 596 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Infobip.php: -------------------------------------------------------------------------------- 1 | ['to' => \ltrim($number, '+')], $message->getTo()); 44 | 45 | $response = new Response($this->getType()); 46 | 47 | $result = $this->request( 48 | method: 'POST', 49 | url: "https://{$this->apiBaseUrl}/sms/2/text/advanced", 50 | headers: [ 51 | 'Content-Type: application/json', 52 | 'Authorization: App '.$this->apiKey, 53 | ], 54 | body: [ 55 | 'messages' => [ 56 | 'text' => $message->getContent(), 57 | 'from' => $this->from ?? $message->getFrom(), 58 | 'destinations' => $to, 59 | ], 60 | ], 61 | ); 62 | 63 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 64 | $response->setDeliveredTo(\count($message->getTo())); 65 | foreach ($message->getTo() as $to) { 66 | $response->addResult($to); 67 | } 68 | } else { 69 | foreach ($message->getTo() as $to) { 70 | $response->addResult($to, 'Unknown error.'); 71 | } 72 | } 73 | 74 | return $response->toArray(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Inforu.php: -------------------------------------------------------------------------------- 1 | getType()); 43 | 44 | $recipients = array_map( 45 | fn ($number) => ['Phone' => ltrim($number, '+')], 46 | $message->getTo() 47 | ); 48 | 49 | $result = $this->request( 50 | method: 'POST', 51 | url: 'https://capi.inforu.co.il/api/v2/SMS/SendSms', 52 | headers: [ 53 | 'Content-Type: application/json', 54 | 'Authorization: Basic ' . $this->apiToken, 55 | ], 56 | body: [ 57 | 'Data' => [ 58 | 'Message' => $message->getContent(), 59 | 'Recipients' => $recipients, 60 | 'Settings' => [ 61 | 'Sender' => $this->senderId, 62 | ], 63 | ], 64 | ], 65 | ); 66 | 67 | if ($result['statusCode'] === 200 && ($result['response']['StatusId'] ?? 0) === 1) { 68 | $response->setDeliveredTo(count($message->getTo())); 69 | foreach ($message->getTo() as $to) { 70 | $response->addResult($to); 71 | } 72 | } else { 73 | $errorMessage = $result['response']['StatusDescription'] ?? 'Unknown error'; 74 | foreach ($message->getTo() as $to) { 75 | $response->addResult($to, $errorMessage); 76 | } 77 | } 78 | 79 | return $response->toArray(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Mock.php: -------------------------------------------------------------------------------- 1 | getType()); 41 | 42 | $response->setDeliveredTo(\count($message->getTo())); 43 | 44 | $result = $this->request( 45 | method: 'POST', 46 | url: 'http://request-catcher:5000/mock-sms', 47 | headers: [ 48 | 'Content-Type: application/json', 49 | "X-Username: {$this->user}", 50 | "X-Key: {$this->secret}", 51 | ], 52 | body: [ 53 | 'message' => $message->getContent(), 54 | 'from' => $message->getFrom(), 55 | 'to' => \implode(',', $message->getTo()), 56 | ], 57 | ); 58 | 59 | if ($result['statusCode'] === 200) { 60 | $response->setDeliveredTo(\count($message->getTo())); 61 | foreach ($message->getTo() as $to) { 62 | $response->addResult($to); 63 | } 64 | } else { 65 | foreach ($message->getTo() as $to) { 66 | $response->addResult($to, 'Unknown Error.'); 67 | } 68 | } 69 | 70 | return $response->toArray(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Msg91.php: -------------------------------------------------------------------------------- 1 | getTo() as $recipient) { 46 | $recipients[] = [ 47 | 'mobiles' => \ltrim($recipient, '+'), 48 | 'content' => $message->getContent(), 49 | 'otp' => $message->getContent(), 50 | ]; 51 | } 52 | 53 | $response = new Response($this->getType()); 54 | $result = $this->request( 55 | method: 'POST', 56 | url: 'https://api.msg91.com/api/v5/flow/', 57 | headers: [ 58 | 'Content-Type: application/json', 59 | 'Authkey: '. $this->authKey, 60 | ], 61 | body: [ 62 | 'sender' => $this->senderId, 63 | 'template_id' => $this->templateId, 64 | 'recipients' => $recipients, 65 | ], 66 | ); 67 | 68 | if ($result['statusCode'] === 200) { 69 | $response->setDeliveredTo(\count($message->getTo())); 70 | foreach ($message->getTo() as $to) { 71 | $response->addResult($to); 72 | } 73 | } else { 74 | foreach ($message->getTo() as $to) { 75 | $response->addResult($to, 'Unknown error'); 76 | } 77 | } 78 | 79 | return $response->toArray(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Plivo.php: -------------------------------------------------------------------------------- 1 | getType()); 44 | 45 | $result = $this->request( 46 | method: 'POST', 47 | url: "https://api.plivo.com/v1/Account/{$this->authId}/Message/", 48 | headers: [ 49 | 'Content-Type: application/x-www-form-urlencoded', 50 | 'Authorization: Basic '.base64_encode("{$this->authId}:{$this->authToken}"), 51 | ], 52 | body: [ 53 | 'text' => $message->getContent(), 54 | 'src' => $this->from ?? $message->getFrom() ?? 'Plivo', 55 | 'dst' => \implode('<', $message->getTo()), 56 | ], 57 | ); 58 | 59 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 60 | $response->setDeliveredTo(\count($message->getTo())); 61 | foreach ($message->getTo() as $to) { 62 | $response->addResult($to); 63 | } 64 | } else { 65 | foreach ($message->getTo() as $to) { 66 | $response->addResult($to, 'Unknown error.'); 67 | } 68 | } 69 | 70 | return $response->toArray(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Seven.php: -------------------------------------------------------------------------------- 1 | getType()); 42 | 43 | $result = $this->request( 44 | method: 'POST', 45 | url: 'https://gateway.sms77.io/api/sms', 46 | headers: [ 47 | 'Content-Type: application/json', 48 | 'Authorization: Basic '.$this->apiKey, 49 | ], 50 | body: [ 51 | 'from' => $this->from ?? $message->getFrom(), 52 | 'to' => \implode(',', $message->getTo()), 53 | 'text' => $message->getContent(), 54 | ], 55 | ); 56 | 57 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 58 | $response->setDeliveredTo(\count($message->getTo())); 59 | foreach ($message->getTo() as $to) { 60 | $response->addResult($to); 61 | } 62 | } else { 63 | foreach ($message->getTo() as $to) { 64 | $response->addResult($to, 'Unknown error.'); 65 | } 66 | } 67 | 68 | return $response->toArray(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Sinch.php: -------------------------------------------------------------------------------- 1 | \ltrim($number, '+'), $message->getTo()); 44 | 45 | $response = new Response($this->getType()); 46 | 47 | $result = $this->request( 48 | method: 'POST', 49 | url: "https://sms.api.sinch.com/xms/v1/{$this->servicePlanId}/batches", 50 | headers: [ 51 | 'Content-Type: application/json', 52 | 'Authorization: Bearer '.$this->apiToken, 53 | ], 54 | body: [ 55 | 'from' => $this->from ?? $message->getFrom(), 56 | 'to' => $to, 57 | 'body' => $message->getContent(), 58 | ], 59 | ); 60 | 61 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 62 | $response->setDeliveredTo(\count($message->getTo())); 63 | foreach ($message->getTo() as $to) { 64 | $response->addResult($to); 65 | } 66 | } else { 67 | foreach ($message->getTo() as $to) { 68 | $response->addResult($to, 'Unknown error.'); 69 | } 70 | } 71 | 72 | return $response->toArray(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Telesign.php: -------------------------------------------------------------------------------- 1 | formatNumbers(\array_map( 44 | fn ($to) => $to, 45 | $message->getTo() 46 | )); 47 | 48 | $response = new Response($this->getType()); 49 | 50 | $result = $this->request( 51 | method: 'POST', 52 | url: 'https://rest-ww.telesign.com/v1/verify/bulk_sms', 53 | headers: [ 54 | 'Content-Type: application/x-www-form-urlencoded', 55 | 'Authorization: Basic '.base64_encode("{$this->customerId}:{$this->apiKey}"), 56 | ], 57 | body: [ 58 | 'template' => $message->getContent(), 59 | 'recipients' => $to, 60 | ], 61 | ); 62 | 63 | if ($result['statusCode'] === 200) { 64 | $response->setDeliveredTo(\count($message->getTo())); 65 | foreach ($message->getTo() as $to) { 66 | $response->addResult($to); 67 | } 68 | } else { 69 | foreach ($message->getTo() as $to) { 70 | if (!\is_null($result['response']['errors'][0]['description'] ?? null)) { 71 | $response->addResult($to, $result['response']['errors'][0]['description']); 72 | } else { 73 | $response->addResult($to, 'Unknown error'); 74 | } 75 | } 76 | } 77 | 78 | return $response->toArray(); 79 | } 80 | 81 | /** 82 | * @param array $numbers 83 | */ 84 | private function formatNumbers(array $numbers): string 85 | { 86 | $formatted = \array_map( 87 | fn ($number) => $number.':'.\uniqid(), 88 | $numbers 89 | ); 90 | 91 | return implode(',', $formatted); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Telnyx.php: -------------------------------------------------------------------------------- 1 | getType()); 40 | 41 | $result = $this->request( 42 | method: 'POST', 43 | url: 'https://api.telnyx.com/v2/messages', 44 | headers: [ 45 | 'Content-Type: application/json', 46 | 'Authorization: Bearer '.$this->apiKey, 47 | ], 48 | body: [ 49 | 'text' => $message->getContent(), 50 | 'from' => $this->from ?? $message->getFrom(), 51 | 'to' => $message->getTo()[0], 52 | ], 53 | ); 54 | 55 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 56 | $response->setDeliveredTo(\count($message->getTo())); 57 | foreach ($message->getTo() as $to) { 58 | $response->addResult($to); 59 | } 60 | } else { 61 | foreach ($message->getTo() as $to) { 62 | $response->addResult($to, 'Unknown error.'); 63 | } 64 | } 65 | 66 | return $response->toArray(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/TextMagic.php: -------------------------------------------------------------------------------- 1 | \ltrim($to, '+'), 46 | $message->getTo() 47 | ); 48 | 49 | $response = new Response($this->getType()); 50 | $result = $this->request( 51 | method: 'POST', 52 | url: 'https://rest.textmagic.com/api/v2/messages', 53 | headers: [ 54 | 'Content-Type: application/x-www-form-urlencoded', 55 | 'X-TM-Username: ' . $this->username, 56 | 'X-TM-Key: '. $this->apiKey, 57 | ], 58 | body: [ 59 | 'text' => $message->getContent(), 60 | 'from' => \ltrim($this->from ?? $message->getFrom(), '+'), 61 | 'phones' => \implode(',', $to), 62 | ], 63 | ); 64 | 65 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 66 | $response->setDeliveredTo(\count($message->getTo())); 67 | foreach ($message->getTo() as $to) { 68 | $response->addResult($to); 69 | } 70 | } else { 71 | foreach ($message->getTo() as $to) { 72 | if (!\is_null($result['response']['message'] ?? null)) { 73 | $response->addResult($to, $result['response']['message']); 74 | } else { 75 | $response->addResult($to, 'Unknown error'); 76 | } 77 | } 78 | } 79 | 80 | return $response->toArray(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Twilio.php: -------------------------------------------------------------------------------- 1 | getType()); 41 | 42 | $result = $this->request( 43 | method: 'POST', 44 | url: "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", 45 | headers: [ 46 | 'Content-Type: application/x-www-form-urlencoded', 47 | 'Authorization: Basic '.base64_encode("{$this->accountSid}:{$this->authToken}"), 48 | ], 49 | body: [ 50 | 'Body' => $message->getContent(), 51 | 'From' => $this->from ?? $message->getFrom(), 52 | 'MessagingServiceSid' => $this->messagingServiceSid ?? null, 53 | 'To' => $message->getTo()[0], 54 | ], 55 | ); 56 | 57 | if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { 58 | $response->setDeliveredTo(1); 59 | $response->addResult($message->getTo()[0]); 60 | } else { 61 | if (!\is_null($result['response']['message'] ?? null)) { 62 | $response->addResult($message->getTo()[0], $result['response']['message']); 63 | } else { 64 | $response->addResult($message->getTo()[0], 'Unknown error'); 65 | } 66 | } 67 | 68 | return $response->toArray(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Adapter/SMS/Vonage.php: -------------------------------------------------------------------------------- 1 | \ltrim($to, '+'), 44 | $message->getTo() 45 | ); 46 | 47 | $response = new Response($this->getType()); 48 | $result = $this->request( 49 | method: 'POST', 50 | url: 'https://rest.nexmo.com/sms/json', 51 | headers: [ 52 | 'Content-Type' => 'application/x-www-form-urlencoded', 53 | ], 54 | body: [ 55 | 'text' => $message->getContent(), 56 | 'from' => $this->from ?? $message->getFrom(), 57 | 'to' => $to[0], //\implode(',', $to), 58 | 'api_key' => $this->apiKey, 59 | 'api_secret' => $this->apiSecret, 60 | ], 61 | ); 62 | 63 | if (($result['response']['messages'][0]['status'] ?? null) === 0) { 64 | $response->setDeliveredTo(1); 65 | $response->addResult($result['response']['messages'][0]['to']); 66 | } else { 67 | if (!\is_null($result['response']['messages'][0]['error-text'] ?? null)) { 68 | $response->addResult($message->getTo()[0], $result['response']['messages'][0]['error-text']); 69 | } else { 70 | $response->addResult($message->getTo()[0], 'Unknown error'); 71 | } 72 | } 73 | 74 | return $response->toArray(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Helpers/JWT.php: -------------------------------------------------------------------------------- 1 | ['openssl', OPENSSL_ALGO_SHA384], 9 | 'ES256' => ['openssl', OPENSSL_ALGO_SHA256], 10 | 'ES256K' => ['openssl', OPENSSL_ALGO_SHA256], 11 | 'RS256' => ['openssl', OPENSSL_ALGO_SHA256], 12 | 'RS384' => ['openssl', OPENSSL_ALGO_SHA384], 13 | 'RS512' => ['openssl', OPENSSL_ALGO_SHA512], 14 | 'HS256' => ['hash_hmac', 'SHA256'], 15 | 'HS384' => ['hash_hmac', 'SHA384'], 16 | 'HS512' => ['hash_hmac', 'SHA512'], 17 | ]; 18 | 19 | /** 20 | * Convert an array to a JWT, signed with the given key and algorithm. 21 | * 22 | * @param array $payload 23 | * 24 | * @throws \Exception 25 | */ 26 | public static function encode(array $payload, string $key, string $algorithm, string $keyId = null): string 27 | { 28 | $header = [ 29 | 'typ' => 'JWT', 30 | 'alg' => $algorithm, 31 | ]; 32 | 33 | if (!\is_null($keyId)) { 34 | $header['kid'] = $keyId; 35 | } 36 | 37 | $header = \json_encode($header, \JSON_UNESCAPED_SLASHES); 38 | $payload = \json_encode($payload, \JSON_UNESCAPED_SLASHES); 39 | 40 | $segments = []; 41 | $segments[] = self::safeBase64Encode($header); 42 | $segments[] = self::safeBase64Encode($payload); 43 | 44 | $signingMaterial = \implode('.', $segments); 45 | 46 | $signature = self::sign($signingMaterial, $key, $algorithm); 47 | 48 | $segments[] = self::safeBase64Encode($signature); 49 | 50 | return \implode('.', $segments); 51 | } 52 | 53 | /** 54 | * @throws \Exception 55 | */ 56 | private static function sign(string $data, string $key, string $alg): string 57 | { 58 | if (empty(static::ALGORITHMS[$alg])) { 59 | throw new \Exception('Algorithm not supported'); 60 | } 61 | 62 | [$function, $algorithm] = static::ALGORITHMS[$alg]; 63 | 64 | switch ($function) { 65 | case 'openssl': 66 | $signature = ''; 67 | 68 | $success = \openssl_sign($data, $signature, $key, $algorithm); 69 | 70 | if (!$success) { 71 | throw new \Exception('OpenSSL sign failed for JWT'); 72 | } 73 | 74 | switch ($alg) { 75 | case 'ES256': 76 | case 'ES256K': 77 | $signature = self::signatureFromDER($signature, 256); 78 | break; 79 | case 'ES384': 80 | $signature = self::signatureFromDER($signature, 384); 81 | break; 82 | default: 83 | break; 84 | } 85 | 86 | return $signature; 87 | case 'hash_hmac': 88 | return \hash_hmac($algorithm, $data, $key, true); 89 | default: 90 | throw new \Exception('Algorithm not supported'); 91 | } 92 | } 93 | 94 | /** 95 | * Encodes signature from a DER object. 96 | * 97 | * @param string $der binary signature in DER format 98 | * @param int $keySize the number of bits in the key 99 | */ 100 | private static function signatureFromDER(string $der, int $keySize): string 101 | { 102 | // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE 103 | [$offset, $_] = self::readDER($der); 104 | [$offset, $r] = self::readDER($der, $offset); 105 | [$_, $s] = self::readDER($der, $offset); 106 | 107 | // Convert r-value and s-value from signed two's compliment to unsigned big-endian integers 108 | $r = \ltrim($r, "\x00"); 109 | $s = \ltrim($s, "\x00"); 110 | 111 | // Pad out r and s so that they are $keySize bits long 112 | $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); 113 | $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); 114 | 115 | return $r.$s; 116 | } 117 | 118 | /** 119 | * Reads binary DER-encoded data and decodes into a single object 120 | * 121 | * @param int $offset 122 | * to decode 123 | * @return array{int, string|null} 124 | */ 125 | private static function readDER(string $der, int $offset = 0): array 126 | { 127 | $pos = $offset; 128 | $size = \strlen($der); 129 | $constructed = (\ord($der[$pos]) >> 5) & 0x01; 130 | $type = \ord($der[$pos++]) & 0x1F; 131 | 132 | // Length 133 | $len = \ord($der[$pos++]); 134 | if ($len & 0x80) { 135 | $n = $len & 0x1F; 136 | $len = 0; 137 | while ($n-- && $pos < $size) { 138 | $len = ($len << 8) | \ord($der[$pos++]); 139 | } 140 | } 141 | 142 | // Value 143 | if ($type === 0x03) { 144 | $pos++; // Skip the first contents octet (padding indicator) 145 | $data = \substr($der, $pos, $len - 1); 146 | $pos += $len - 1; 147 | } elseif (!$constructed) { 148 | $data = \substr($der, $pos, $len); 149 | $pos += $len; 150 | } else { 151 | $data = null; 152 | } 153 | 154 | return [$pos, $data]; 155 | } 156 | 157 | /** 158 | * Encode a string with URL-safe Base64. 159 | */ 160 | private static function safeBase64Encode(string $input): string 161 | { 162 | return \str_replace(['+', '/', '='], ['-', '_', ''], \base64_encode($input)); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Message.php: -------------------------------------------------------------------------------- 1 | |null $embeds 11 | * @param array|null $allowedMentions 12 | * @param array|null $components 13 | * @param array|null $attachments 14 | */ 15 | public function __construct( 16 | private string $content, 17 | private ?string $username = null, 18 | private ?string $avatarUrl = null, 19 | private ?bool $tts = null, 20 | private ?array $embeds = null, 21 | private ?array $allowedMentions = null, 22 | private ?array $components = null, 23 | private ?array $attachments = null, 24 | private ?string $flags = null, 25 | private ?string $threadName = null, 26 | private ?bool $wait = null, 27 | private ?string $threadId = null 28 | ) { 29 | } 30 | 31 | public function getContent(): string 32 | { 33 | return $this->content; 34 | } 35 | 36 | public function getUsername(): ?string 37 | { 38 | return $this->username; 39 | } 40 | 41 | public function getAvatarUrl(): ?string 42 | { 43 | return $this->avatarUrl; 44 | } 45 | 46 | public function getTts(): ?bool 47 | { 48 | return $this->tts; 49 | } 50 | 51 | /** 52 | * @return array|null 53 | */ 54 | public function getEmbeds(): ?array 55 | { 56 | return $this->embeds; 57 | } 58 | 59 | /** 60 | * @return array|null 61 | */ 62 | public function getAllowedMentions(): ?array 63 | { 64 | return $this->allowedMentions; 65 | } 66 | 67 | /** 68 | * @return array|null 69 | */ 70 | public function getComponents(): ?array 71 | { 72 | return $this->components; 73 | } 74 | 75 | /** 76 | * @return array|null 77 | */ 78 | public function getAttachments(): ?array 79 | { 80 | return $this->attachments; 81 | } 82 | 83 | public function getFlags(): ?string 84 | { 85 | return $this->flags; 86 | } 87 | 88 | public function getThreadName(): ?string 89 | { 90 | return $this->threadName; 91 | } 92 | 93 | public function getWait(): ?bool 94 | { 95 | return $this->wait; 96 | } 97 | 98 | public function getThreadId(): ?string 99 | { 100 | return $this->threadId; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Messages/Email.php: -------------------------------------------------------------------------------- 1 | $to The recipients of the email. 12 | * @param string $subject The subject of the email. 13 | * @param string $content The content of the email. 14 | * @param string $fromName The name of the sender. 15 | * @param string $fromEmail The email address of the sender. 16 | * @param array>|null $cc . The CC recipients of the email. Each recipient should be an array containing a "name" and an "email" key. 17 | * @param array>|null $bcc . The BCC recipients of the email. Each recipient should be an array containing a "name" and an "email" key. 18 | * @param string|null $replyToName The name of the reply to. 19 | * @param string|null $replyToEmail The email address of the reply to. 20 | * @param array|null $attachments The attachments of the email. 21 | * @param bool $html Whether the message is HTML or not. 22 | * @param string|null $defaultRecipient The default recipient of the email. 23 | * 24 | * @throws \InvalidArgumentException 25 | */ 26 | public function __construct( 27 | private array $to, 28 | private string $subject, 29 | private string $content, 30 | private string $fromName, 31 | private string $fromEmail, 32 | private ?string $replyToName = null, 33 | private ?string $replyToEmail = null, 34 | private ?array $cc = null, 35 | private ?array $bcc = null, 36 | private ?array $attachments = null, 37 | private bool $html = false, 38 | private ?string $defaultRecipient = null 39 | ) { 40 | if (\is_null($this->replyToName)) { 41 | $this->replyToName = $this->fromName; 42 | } 43 | 44 | if (\is_null($this->replyToEmail)) { 45 | $this->replyToEmail = $this->fromEmail; 46 | } 47 | 48 | if (!\is_null($this->cc)) { 49 | foreach ($this->cc as $recipient) { 50 | if (!isset($recipient['email'])) { 51 | throw new \InvalidArgumentException('Each CC recipient must have at least an email'); 52 | } 53 | } 54 | } 55 | 56 | if (!\is_null($this->bcc)) { 57 | foreach ($this->bcc as $recipient) { 58 | if (!isset($recipient['email'])) { 59 | throw new \InvalidArgumentException('Each BCC recipient must have at least an email'); 60 | } 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * @return array 67 | */ 68 | public function getTo(): array 69 | { 70 | return $this->to; 71 | } 72 | 73 | public function getSubject(): string 74 | { 75 | return $this->subject; 76 | } 77 | 78 | public function getContent(): string 79 | { 80 | return $this->content; 81 | } 82 | 83 | public function getFromName(): string 84 | { 85 | return $this->fromName; 86 | } 87 | 88 | public function getFromEmail(): string 89 | { 90 | return $this->fromEmail; 91 | } 92 | 93 | public function getReplyToName(): string 94 | { 95 | return $this->replyToName; 96 | } 97 | 98 | public function getReplyToEmail(): string 99 | { 100 | return $this->replyToEmail; 101 | } 102 | 103 | /** 104 | * @return array>|null 105 | */ 106 | public function getCC(): ?array 107 | { 108 | return $this->cc; 109 | } 110 | 111 | /** 112 | * @return array>|null 113 | */ 114 | public function getBCC(): ?array 115 | { 116 | return $this->bcc; 117 | } 118 | 119 | /** 120 | * @return array|null 121 | */ 122 | public function getAttachments(): ?array 123 | { 124 | return $this->attachments; 125 | } 126 | 127 | public function isHtml(): bool 128 | { 129 | return $this->html; 130 | } 131 | 132 | public function getDefaultRecipient(): ?string 133 | { 134 | return $this->defaultRecipient; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Messages/Email/Attachment.php: -------------------------------------------------------------------------------- 1 | name; 22 | } 23 | 24 | public function getPath(): string 25 | { 26 | return $this->path; 27 | } 28 | 29 | public function getType(): string 30 | { 31 | return $this->type; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Messages/Push.php: -------------------------------------------------------------------------------- 1 | $to The recipients of the push notification. 12 | * @param string|null $title The title of the push notification. 13 | * @param string|null $body The body of the push notification. 14 | * @param array|null $data This parameter specifies the custom key-value pairs of the message's payload. For example, with data:{"score":"3x1"}:

On Apple platforms, if the message is sent via APNs, it represents the custom data fields. If it is sent via FCM, it would be represented as key value dictionary in AppDelegate application:didReceiveRemoteNotification:.

On Android, this would result in an intent extra named score with the string value 3x1.

The key should not be a reserved word ("from", "message_type", or any word starting with "google" or "gcm"). Do not use any of the words defined in this table (such as collapse_key).

Values in string types are recommended. You have to convert values in objects or other non-string data types (e.g., integers or booleans) to string. 15 | * @param string|null $action The action associated with a user click on the notification.

On Android, this is the activity to launch.

On iOS, this is the category to launch. 16 | * @param string|null $sound The sound to play when the device receives the notification.

On Android, sound files must reside in /res/raw/.

On iOS, sounds files must reside in the main bundle of the client app or in the Library/Sounds folder of the app's data container. 17 | * @param string|null $image The image to display when the device receives the notification.

On Android, this image is displayed as a badge on the notification.

On iOS, this image is displayed next to the body of the notification. If present, the notification's type is set to media. 18 | * @param string|null $icon Android only. The icon of the push notification. Sets the notification icon to myicon for drawable resource myicon. If you don't send this key in the request, FCM displays the launcher icon specified in your app manifest. 19 | * @param string|null $color Android only. The icon color of the push notification, expressed in #rrggbb format. 20 | * @param string|null $tag Android only. Identifier used to replace existing notifications in the notification drawer.

If not specified, each request creates a new notification.

If specified and a notification with the same tag is already being shown, the new notification replaces the existing one in the notification drawer. 21 | * @param int|null $badge iOS only. The value of the badge on the home screen app icon. If not specified, the badge is not changed. If set to 0, the badge is removed. 22 | * @param bool|null $contentAvailable iOS only. When set to true, the notification is silent (no sounds or vibrations) and the content-available flag is set to 1. If not specified, the notification is not silent. 23 | * @param bool|null $critical iOS only. When set to true, if the app is granted the critical alert capability, the notification is displayed using Apple's critical alert option. If not specified, the notification is not displayed using Apple's critical alert option. 24 | * @param Priority|null $priority The priority of the message. Valid values are "normal" and "high". On iOS, these correspond to APNs priority 5 and 10.

By default, notification messages are sent with high priority, and data messages are sent with normal priority. 25 | */ 26 | public function __construct( 27 | private array $to, 28 | private ?string $title = null, 29 | private ?string $body = null, 30 | private ?array $data = null, 31 | private ?string $action = null, 32 | private ?string $sound = null, 33 | private ?string $image = null, 34 | private ?string $icon = null, 35 | private ?string $color = null, 36 | private ?string $tag = null, 37 | private ?int $badge = null, 38 | private ?bool $contentAvailable = null, 39 | private ?bool $critical = null, 40 | private ?Priority $priority = null, 41 | ) { 42 | if ( 43 | $title === null 44 | && $body === null 45 | && $data === null 46 | ) { 47 | throw new \Exception('At least one of the following parameters must be set: title, body, data'); 48 | } 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function getTo(): array 55 | { 56 | return $this->to; 57 | } 58 | 59 | public function getFrom(): ?string 60 | { 61 | return null; 62 | } 63 | 64 | public function getTitle(): ?string 65 | { 66 | return $this->title; 67 | } 68 | 69 | public function getBody(): ?string 70 | { 71 | return $this->body; 72 | } 73 | 74 | /** 75 | * @return array|null 76 | */ 77 | public function getData(): ?array 78 | { 79 | return $this->data; 80 | } 81 | 82 | public function getAction(): ?string 83 | { 84 | return $this->action; 85 | } 86 | 87 | public function getSound(): ?string 88 | { 89 | return $this->sound; 90 | } 91 | 92 | public function getImage(): ?string 93 | { 94 | return $this->image; 95 | } 96 | 97 | public function getIcon(): ?string 98 | { 99 | return $this->icon; 100 | } 101 | 102 | public function getColor(): ?string 103 | { 104 | return $this->color; 105 | } 106 | 107 | public function getTag(): ?string 108 | { 109 | return $this->tag; 110 | } 111 | 112 | public function getBadge(): ?int 113 | { 114 | return $this->badge; 115 | } 116 | 117 | public function getContentAvailable(): ?bool 118 | { 119 | return $this->contentAvailable; 120 | } 121 | 122 | public function getCritical(): ?bool 123 | { 124 | return $this->critical; 125 | } 126 | 127 | public function getPriority(): ?Priority 128 | { 129 | return $this->priority; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Messages/SMS.php: -------------------------------------------------------------------------------- 1 | $to 11 | * @param array|null $attachments 12 | */ 13 | public function __construct( 14 | private array $to, 15 | private string $content, 16 | private ?string $from = null, 17 | private ?array $attachments = null, 18 | ) { 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getTo(): array 25 | { 26 | return $this->to; 27 | } 28 | 29 | public function getContent(): string 30 | { 31 | return $this->content; 32 | } 33 | 34 | public function getFrom(): ?string 35 | { 36 | return $this->from; 37 | } 38 | 39 | /** 40 | * @return array|null 41 | */ 42 | public function getAttachments(): ?array 43 | { 44 | return $this->attachments; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Utopia/Messaging/Priority.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | private array $results; 15 | 16 | public function __construct(string $type) 17 | { 18 | $this->type = $type; 19 | $this->deliveredTo = 0; 20 | $this->results = []; 21 | } 22 | 23 | public function setDeliveredTo(int $deliveredTo): void 24 | { 25 | $this->deliveredTo = $deliveredTo; 26 | } 27 | 28 | public function incrementDeliveredTo(): void 29 | { 30 | $this->deliveredTo++; 31 | } 32 | 33 | public function getDeliveredTo(): int 34 | { 35 | return $this->deliveredTo; 36 | } 37 | 38 | public function setType(string $type): void 39 | { 40 | $this->type = $type; 41 | } 42 | 43 | public function getType(): string 44 | { 45 | return $this->type; 46 | } 47 | 48 | /** 49 | * @return array> 50 | */ 51 | public function getDetails(): array 52 | { 53 | return $this->results; 54 | } 55 | 56 | public function addResult(string $recipient, string $error = ''): void 57 | { 58 | $this->results[] = [ 59 | 'recipient' => $recipient, 60 | 'status' => empty($error) ? 'success' : 'failure', 61 | 'error' => $error, 62 | ]; 63 | } 64 | 65 | /** 66 | * @return array{deliveredTo: int, type: string, results: array>} 67 | */ 68 | public function toArray(): array 69 | { 70 | return [ 71 | 'deliveredTo' => $this->deliveredTo, 72 | 'type' => $this->type, 73 | 'results' => $this->results, 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Base.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | protected function getLastRequest(): array 13 | { 14 | \sleep(2); 15 | 16 | $request = \json_decode(\file_get_contents('http://request-catcher:5000/__last_request__'), true); 17 | $request['data'] = \json_decode($request['data'], true); 18 | 19 | return $request; 20 | } 21 | 22 | /** 23 | * @return array 24 | */ 25 | protected function getLastEmail(): array 26 | { 27 | sleep(3); 28 | 29 | $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true); 30 | 31 | if ($emails && \is_array($emails)) { 32 | return \end($emails); 33 | } 34 | 35 | return []; 36 | } 37 | 38 | /** 39 | * @param array $response 40 | */ 41 | protected function assertResponse(array $response): void 42 | { 43 | $this->assertEquals(1, $response['deliveredTo'], \var_export($response, true)); 44 | $this->assertEquals('', $response['results'][0]['error'], \var_export($response, true)); 45 | $this->assertEquals('success', $response['results'][0]['status'], \var_export($response, true)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Chat/DiscordTest.php: -------------------------------------------------------------------------------- 1 | send($message); 26 | 27 | $this->assertResponse($result); 28 | } 29 | 30 | /** 31 | * @return array> 32 | */ 33 | public static function invalidURLProvider(): array 34 | { 35 | return [ 36 | 'invalid URL format' => ['not-a-url'], 37 | 'invalid scheme (http)' => ['http://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz'], 38 | 'invalid host' => ['https://example.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz'], 39 | 'missing path' => ['https://discord.com'], 40 | 'no webhooks segment' => ['https://discord.com/api/invalid/123456789012345678/token'], 41 | 'missing webhook ID' => ['https://discord.com/api/webhooks//token'], 42 | ]; 43 | } 44 | 45 | /** 46 | * @dataProvider invalidURLProvider 47 | */ 48 | public function testInvalidURLs(string $invalidURL): void 49 | { 50 | $this->expectException(InvalidArgumentException::class); 51 | new Discord($invalidURL); 52 | } 53 | 54 | public function testValidURLVariations(): void 55 | { 56 | // Valid URL format variations 57 | $validURLs = [ 58 | 'with api path' => 'https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz', 59 | 'without api path' => 'https://discord.com/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz', 60 | 'with trailing slash' => 'https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz/', 61 | ]; 62 | 63 | foreach ($validURLs as $label => $url) { 64 | try { 65 | $discord = new Discord($url); 66 | // If we get here, the URL was accepted 67 | $this->assertTrue(true, "Valid URL variant '{$label}' was accepted as expected"); 68 | } catch (InvalidArgumentException $e) { 69 | $this->fail("Valid URL variant '{$label}' was rejected: " . $e->getMessage()); 70 | } 71 | } 72 | } 73 | 74 | public function testWebhookIDExtraction(): void 75 | { 76 | // Create a reflection of Discord to access protected properties 77 | $webhookId = '123456789012345678'; 78 | $url = "https://discord.com/api/webhooks/{$webhookId}/abcdefghijklmnopqrstuvwxyz"; 79 | 80 | $discord = new Discord($url); 81 | $reflector = new \ReflectionClass($discord); 82 | $property = $reflector->getProperty('webhookId'); 83 | $property->setAccessible(true); 84 | 85 | $this->assertEquals($webhookId, $property->getValue($discord), 'Webhook ID was not correctly extracted'); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Email/EmailTest.php: -------------------------------------------------------------------------------- 1 | 'tester2@localhost.test']]; 21 | $bcc = [['name' => 'Tester3', 'email' => 'tester3@localhost.test']]; 22 | 23 | $message = new Email( 24 | to: [$to], 25 | subject: $subject, 26 | content: $content, 27 | fromName: $fromName, 28 | fromEmail: $fromEmail, 29 | cc: $cc, 30 | bcc: $bcc, 31 | ); 32 | 33 | $response = $sender->send($message); 34 | 35 | $lastEmail = $this->getLastEmail(); 36 | 37 | $this->assertResponse($response); 38 | $this->assertEquals($to, $lastEmail['to'][0]['address']); 39 | $this->assertEquals($fromEmail, $lastEmail['from'][0]['address']); 40 | $this->assertEquals($fromName, $lastEmail['from'][0]['name']); 41 | $this->assertEquals($subject, $lastEmail['subject']); 42 | $this->assertEquals($content, \trim($lastEmail['text'])); 43 | $this->assertEquals($cc[0]['email'], $lastEmail['cc'][0]['address']); 44 | $this->assertEquals($bcc[0]['email'], $lastEmail['envelope']['to'][2]['address']); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Email/MailgunTest.php: -------------------------------------------------------------------------------- 1 | \getenv('TEST_CC_EMAIL')]]; 28 | $bcc = [['name' => \getenv('TEST_BCC_NAME'), 'email' => \getenv('TEST_BCC_EMAIL')]]; 29 | 30 | $message = new Email( 31 | to: [$to], 32 | subject: $subject, 33 | content: $content, 34 | fromName: 'Test Sender', 35 | fromEmail: $fromEmail, 36 | cc: $cc, 37 | bcc: $bcc, 38 | ); 39 | 40 | $response = $sender->send($message); 41 | 42 | $this->assertResponse($response); 43 | } 44 | 45 | public function testSendEmailWithAttachments(): void 46 | { 47 | $key = \getenv('MAILGUN_API_KEY'); 48 | $domain = \getenv('MAILGUN_DOMAIN'); 49 | 50 | $sender = new Mailgun( 51 | apiKey: $key, 52 | domain: $domain, 53 | isEU: false 54 | ); 55 | 56 | $to = \getenv('TEST_EMAIL'); 57 | $subject = 'Test Subject'; 58 | $content = 'Test Content'; 59 | $fromEmail = 'sender@'.$domain; 60 | 61 | $message = new Email( 62 | to: [$to], 63 | subject: $subject, 64 | content: $content, 65 | fromName: 'Test Sender', 66 | fromEmail: $fromEmail, 67 | attachments: [new Attachment( 68 | name: 'image.png', 69 | path: __DIR__ . '/../../../assets/image.png', 70 | type: 'image/png' 71 | ),], 72 | ); 73 | 74 | $response = $sender->send($message); 75 | 76 | $this->assertResponse($response); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Email/SMTPTest.php: -------------------------------------------------------------------------------- 1 | send($message); 34 | 35 | $lastEmail = $this->getLastEmail(); 36 | 37 | $this->assertResponse($response); 38 | $this->assertEquals($to, $lastEmail['to'][0]['address']); 39 | $this->assertEquals($fromEmail, $lastEmail['from'][0]['address']); 40 | $this->assertEquals($subject, $lastEmail['subject']); 41 | $this->assertEquals($content, \trim($lastEmail['text'])); 42 | } 43 | 44 | public function testSendEmailWithAttachment(): void 45 | { 46 | $sender = new SMTP( 47 | host: 'maildev', 48 | port: 1025, 49 | ); 50 | 51 | $to = 'tester@localhost.test'; 52 | $subject = 'Test Subject'; 53 | $content = 'Test Content'; 54 | $fromName = 'Test Sender'; 55 | $fromEmail = 'sender@localhost.test'; 56 | 57 | $message = new Email( 58 | to: [$to], 59 | subject: $subject, 60 | content: $content, 61 | fromName: $fromName, 62 | fromEmail: $fromEmail, 63 | attachments: [new Attachment( 64 | name: 'image.png', 65 | path: __DIR__ . '/../../../assets/image.png', 66 | type: 'image/png' 67 | )], 68 | ); 69 | 70 | $response = $sender->send($message); 71 | 72 | $lastEmail = $this->getLastEmail(); 73 | 74 | $this->assertResponse($response); 75 | $this->assertEquals($to, $lastEmail['to'][0]['address']); 76 | $this->assertEquals($fromEmail, $lastEmail['from'][0]['address']); 77 | $this->assertEquals($subject, $lastEmail['subject']); 78 | $this->assertEquals($content, \trim($lastEmail['text'])); 79 | } 80 | 81 | public function testSendEmailOnlyBCC(): void 82 | { 83 | $sender = new SMTP( 84 | host: 'maildev', 85 | port: 1025, 86 | ); 87 | 88 | $defaultRecipient = 'tester@localhost.test'; 89 | $subject = 'Test Subject'; 90 | $content = 'Test Content'; 91 | $fromName = 'Test Sender'; 92 | $fromEmail = 'sender@localhost.test'; 93 | $bcc = [ 94 | [ 95 | 'email' => 'tester2@localhost.test', 96 | 'name' => 'Test Recipient 2', 97 | ], 98 | ]; 99 | 100 | $message = new Email( 101 | to: [], 102 | subject: $subject, 103 | content: $content, 104 | fromName: $fromName, 105 | fromEmail: $fromEmail, 106 | bcc: $bcc, 107 | defaultRecipient: $defaultRecipient, 108 | ); 109 | 110 | $response = $sender->send($message); 111 | 112 | $lastEmail = $this->getLastEmail(); 113 | 114 | $this->assertResponse($response); 115 | $this->assertEquals($fromEmail, $lastEmail['from'][0]['address']); 116 | $this->assertEquals($subject, $lastEmail['subject']); 117 | $this->assertEquals($content, \trim($lastEmail['text'])); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Email/SendgridTest.php: -------------------------------------------------------------------------------- 1 | \getenv('TEST_CC_EMAIL')]]; 22 | $bcc = [['name' => \getenv('TEST_BCC_NAME'), 'email' => \getenv('TEST_BCC_EMAIL')]]; 23 | 24 | $message = new Email( 25 | to: [$to], 26 | subject: $subject, 27 | content: $content, 28 | fromName: 'Tester', 29 | fromEmail: $fromEmail, 30 | cc: $cc, 31 | bcc: $bcc, 32 | ); 33 | 34 | $response = $sender->send($message); 35 | 36 | $this->assertResponse($response); 37 | } 38 | 39 | public function testSendEmailWithAttachment(): void 40 | { 41 | $key = \getenv('SENDGRID_API_KEY'); 42 | $sender = new Sendgrid($key); 43 | 44 | $to = \getenv('TEST_EMAIL'); 45 | $subject = 'Test Subject'; 46 | $content = 'Test Content'; 47 | $fromEmail = \getenv('TEST_FROM_EMAIL'); 48 | 49 | $message = new Email( 50 | to: [$to], 51 | subject: $subject, 52 | content: $content, 53 | fromName: 'Tester', 54 | fromEmail: $fromEmail, 55 | attachments: [new Attachment( 56 | name: 'image.png', 57 | path: __DIR__ . '/../../../assets/image.png', 58 | type: 'image/png' 59 | )], 60 | ); 61 | 62 | $response = $sender->send($message); 63 | 64 | $this->assertResponse($response); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Push/APNSTest.php: -------------------------------------------------------------------------------- 1 | adapter = new APNSAdapter( 19 | $authKey, 20 | $authKeyId, 21 | $teamId, 22 | $bundleId, 23 | sandbox: true 24 | ); 25 | } 26 | 27 | protected function getTo(): array 28 | { 29 | return [\getenv('APNS_TO')]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Push/Base.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | abstract protected function getTo(): array; 18 | 19 | public function testSend(): void 20 | { 21 | $message = new Push( 22 | to: $this->getTo(), 23 | title: 'Test title', 24 | body: 'Test body', 25 | data: null, 26 | action: null, 27 | sound: 'default', 28 | icon: null, 29 | color: null, 30 | tag: null, 31 | badge: 1, 32 | ); 33 | 34 | $response = $this->adapter->send($message); 35 | 36 | $this->assertResponse($response); 37 | } 38 | 39 | public function testSendSilent(): void 40 | { 41 | $message = new Push( 42 | to: $this->getTo(), 43 | data: [ 44 | 'key' => 'value', 45 | ], 46 | contentAvailable: true 47 | ); 48 | 49 | $response = $this->adapter->send($message); 50 | 51 | $this->assertResponse($response); 52 | } 53 | 54 | public function testSendCritical(): void 55 | { 56 | $message = new Push( 57 | to: $this->getTo(), 58 | title: 'Test title', 59 | body: 'Test body', 60 | critical: true 61 | ); 62 | 63 | $response = $this->adapter->send($message); 64 | 65 | $this->assertResponse($response); 66 | } 67 | 68 | public function testSendPriority(): void 69 | { 70 | $message = new Push( 71 | to: $this->getTo(), 72 | title: 'Test title', 73 | body: 'Test body', 74 | priority: Priority::HIGH 75 | ); 76 | 77 | $response = $this->adapter->send($message); 78 | 79 | $this->assertResponse($response); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/Push/FCMTest.php: -------------------------------------------------------------------------------- 1 | adapter = new FCMAdapter($serverKey); 16 | } 17 | 18 | protected function getTo(): array 19 | { 20 | return [\getenv('FCM_TO')]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/Fast2SMSTest.php: -------------------------------------------------------------------------------- 1 | send($message); 27 | 28 | $this->assertResponse($response); 29 | } 30 | 31 | /** 32 | * Test DLT route 33 | */ 34 | public function testDLTSMS(): void 35 | { 36 | $sender = new Fast2SMS( 37 | apiKey: getenv('FAST2SMS_API_KEY'), 38 | senderId: getenv('FAST2SMS_SENDER_ID'), 39 | messageId: getenv('FAST2SMS_MESSAGE_ID'), 40 | useDLT: true 41 | ); 42 | 43 | $message = new SMS( 44 | to: [getenv('FAST2SMS_TO')], 45 | content: '12345', 46 | ); 47 | 48 | $response = $sender->send($message); 49 | 50 | $this->assertResponse($response); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/GEOSMS/CallingCodeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(CallingCode::NORTH_AMERICA, CallingCode::fromPhoneNumber('+11234567890')); 13 | $this->assertEquals(CallingCode::INDIA, CallingCode::fromPhoneNumber('+911234567890')); 14 | $this->assertEquals(CallingCode::ISRAEL, CallingCode::fromPhoneNumber('9721234567890')); 15 | $this->assertEquals(CallingCode::UNITED_ARAB_EMIRATES, CallingCode::fromPhoneNumber('009711234567890')); 16 | $this->assertEquals(CallingCode::UNITED_KINGDOM, CallingCode::fromPhoneNumber('011441234567890')); 17 | $this->assertEquals(null, CallingCode::fromPhoneNumber('2')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/GEOSMSTest.php: -------------------------------------------------------------------------------- 1 | createMock(SMSAdapter::class); 16 | $defaultAdapterMock->method('getName')->willReturn('default'); 17 | $defaultAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); 18 | 19 | $adapter = new GEOSMS($defaultAdapterMock); 20 | 21 | $to = ['+11234567890']; 22 | $from = 'Sender'; 23 | 24 | $message = new SMS( 25 | to: $to, 26 | content: 'Test Content', 27 | from: $from 28 | ); 29 | 30 | $result = $adapter->send($message); 31 | 32 | $this->assertEquals(1, count($result)); 33 | $this->assertEquals('success', $result['default']['results'][0]['status']); 34 | } 35 | 36 | public function testSendSMSUsingLocalAdapter(): void 37 | { 38 | $defaultAdapterMock = $this->createMock(SMSAdapter::class); 39 | $localAdapterMock = $this->createMock(SMSAdapter::class); 40 | $localAdapterMock->method('getName')->willReturn('local'); 41 | $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); 42 | 43 | $adapter = new GEOSMS($defaultAdapterMock); 44 | $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); 45 | 46 | $to = ['+911234567890']; 47 | $from = 'Sender'; 48 | 49 | $message = new SMS( 50 | to: $to, 51 | content: 'Test Content', 52 | from: $from 53 | ); 54 | 55 | $result = $adapter->send($message); 56 | 57 | $this->assertEquals(1, count($result)); 58 | $this->assertEquals('success', $result['local']['results'][0]['status']); 59 | } 60 | 61 | public function testSendSMSUsingLocalAdapterAndDefault(): void 62 | { 63 | $defaultAdapterMock = $this->createMock(SMSAdapter::class); 64 | $defaultAdapterMock->method('getName')->willReturn('default'); 65 | $defaultAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); 66 | $localAdapterMock = $this->createMock(SMSAdapter::class); 67 | $localAdapterMock->method('getName')->willReturn('local'); 68 | $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); 69 | 70 | $adapter = new GEOSMS($defaultAdapterMock); 71 | $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); 72 | 73 | $to = ['+911234567890', '+11234567890']; 74 | $from = 'Sender'; 75 | 76 | $message = new SMS( 77 | to: $to, 78 | content: 'Test Content', 79 | from: $from 80 | ); 81 | 82 | $result = $adapter->send($message); 83 | 84 | $this->assertEquals(2, count($result)); 85 | $this->assertEquals('success', $result['local']['results'][0]['status']); 86 | $this->assertEquals('success', $result['default']['results'][0]['status']); 87 | } 88 | 89 | public function testSendSMSUsingGroupedLocalAdapter(): void 90 | { 91 | $defaultAdapterMock = $this->createMock(SMSAdapter::class); 92 | $localAdapterMock = $this->createMock(SMSAdapter::class); 93 | $localAdapterMock->method('getName')->willReturn('local'); 94 | $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); 95 | 96 | $adapter = new GEOSMS($defaultAdapterMock); 97 | $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); 98 | $adapter->setLocal(CallingCode::NORTH_AMERICA, $localAdapterMock); 99 | 100 | $to = ['+911234567890', '+11234567890']; 101 | $from = 'Sender'; 102 | 103 | $message = new SMS( 104 | to: $to, 105 | content: 'Test Content', 106 | from: $from 107 | ); 108 | 109 | $result = $adapter->send($message); 110 | 111 | $this->assertEquals(1, count($result)); 112 | $this->assertEquals('success', $result['local']['results'][0]['status']); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/InforuTest.php: -------------------------------------------------------------------------------- 1 | send($message); 27 | 28 | $this->assertResponse($response); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/Msg91Test.php: -------------------------------------------------------------------------------- 1 | send($message); 21 | 22 | $this->assertResponse($response); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/SMSTest.php: -------------------------------------------------------------------------------- 1 | send($message); 25 | 26 | $smsRequest = $this->getLastRequest(); 27 | 28 | $this->assertEquals('http://request-catcher:5000/mock-sms', $smsRequest['url']); 29 | $this->assertEquals('Appwrite Mock Message Sender', $smsRequest['headers']['User-Agent']); 30 | $this->assertEquals('username', $smsRequest['headers']['X-Username']); 31 | $this->assertEquals('password', $smsRequest['headers']['X-Key']); 32 | $this->assertEquals('POST', $smsRequest['method']); 33 | $this->assertEquals('+987654321', $smsRequest['data']['from']); 34 | $this->assertEquals('+123456789', $smsRequest['data']['to']); 35 | $this->assertEquals(98, $sender->getCountryCode($smsRequest['data']['from'])); 36 | $this->assertEquals(1, $sender->getCountryCode($smsRequest['data']['to'])); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/TelesignTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Telesign requires support/sales call in order to enable bulk SMS'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/TelnyxTest.php: -------------------------------------------------------------------------------- 1 | send($message); 23 | 24 | // $this->assertEquals('success', $result["type"]); 25 | 26 | $this->markTestSkipped('Telnyx had no testing numbers available at this time.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/TwilioTest.php: -------------------------------------------------------------------------------- 1 | send($message); 27 | 28 | $this->assertResponse($response); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Messaging/Adapter/SMS/VonageTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Vonage credentials are not available.'); 15 | 16 | /* 17 | $apiKey = \getenv('VONAGE_API_KEY'); 18 | $apiSecret = \getenv('VONAGE_API_SECRET'); 19 | 20 | $sender = new Vonage($apiKey, $apiSecret); 21 | 22 | $message = new SMS( 23 | to: [\getenv('VONAGE_TO')], 24 | content: 'Test Content', 25 | from: \getenv('VONAGE_FROM') 26 | ); 27 | 28 | $response = $sender->send($message); 29 | 30 | $result = \json_decode($response, true); 31 | 32 | $this->assertResponse($result); 33 | */ 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/messaging/c151aa5d4d475c788ca15c210b5b2017e21c41d6/tests/assets/image.png --------------------------------------------------------------------------------